创作中心模块包含首页展示、个人中心、帖子审核。
“首页展示”支持广告轮播展示、推广帖子优先展示、分页显示所有帖子、导航栏便捷标签筛选帖子、全局标题模糊搜索帖子、点击帖子“查看更多”进入帖子详情页。帖子详情页展示帖子封面图片、作者时间、详细内容(可以插入种子链接对种子进行介绍与推广)等基本信息、对帖子点赞收藏举报评论回复、查看相关推荐帖子。相关推荐会推荐当前帖子作者的其他帖子(最多推荐5篇),还会推荐具有相似标签的其他帖子,两者总共最多推荐9篇帖子。
“个人中心”包含“我的中心”和“我的收藏”。
“我的中心”中可以管理已经成功发布的帖子(编辑、删除帖子),还可以发布新帖子。发布新帖子时除了填写帖子基本信息以外,帖子标签支持下拉多项选择,用户还可以选择帖子推广项目并进行支付。设置了多种推广项目,包含广告轮播推广、帖子置顶展示、限时优先展示、分类页首条展示。系统后台执行自动定时任务,每小时对帖子的推广时效性进行检查,如超出推广时限,则取消帖子的推广显示特权。用户点击发布帖子后帖子处于待审核状态,需要管理员审核通过才能正常发布在首页展示页面。编辑帖子时用户可以追加帖子推广,但如果帖子处于推广状态,则禁止修改推广项目。
“我的收藏”中可以便捷查看所有已收藏的帖子。
“帖子审核”包含“帖子发布管理”和“帖子举报管理”。“帖子审核”板块具有权限管理,只有管理员界面能够进入。
“帖子发布管理”对所有待审核帖子进行处理,支持预览待审核帖子详细内容,批准通过和拒绝通过选项。
“帖子举报管理”对所有用户的举报请求进行人工审核,如果举报内容属实,则将帖子下架处理,如果举报内容不属实,驳回举报请求。所有举报请求的处理结果均留存显示,方便后续再次审查。
Change-Id: If822351183e9d55a5a56ff5cf1e13b313fdbe231
diff --git a/src/pages/PostCenter/PostCard.module.css b/src/pages/PostCenter/PostCard.module.css
new file mode 100644
index 0000000..3f0623f
--- /dev/null
+++ b/src/pages/PostCenter/PostCard.module.css
@@ -0,0 +1,192 @@
+.postCardWrapper {
+ width: 100%;
+ height: 100%;
+}
+
+.postCard {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+ background: white;
+}
+
+.postCard:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
+}
+
+.coverContainer {
+ width: 100%;
+ height: 200px;
+ overflow: hidden;
+ position: relative;
+ background: #f5f5f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.coverImage {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ object-position: center;
+ transition: transform 0.3s ease;
+ background: #f5f5f5;
+}
+
+.postCard:hover .coverImage {
+ transform: scale(1.02);
+}
+
+.promotionBadge {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: linear-gradient(45deg, #ff6b6b, #ffa500);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ z-index: 10;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.cardContent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.postTitle {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 12px 0;
+ line-height: 1.4;
+ color: #262626;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ height: 44px;
+}
+
+.postMeta {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: #8c8c8c;
+ margin-bottom: 12px;
+ height: 20px;
+}
+
+.authorName {
+ font-weight: 500;
+ color: #595959;
+}
+
+.publishTime {
+ color: #8c8c8c;
+}
+
+.tagsContainer {
+ margin-bottom: 12px;
+ height: 24px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: hidden;
+}
+
+.tag {
+ font-size: 11px;
+ padding: 2px 6px;
+ margin: 0;
+ border-radius: 4px;
+}
+
+.postSummary {
+ flex: 1;
+ font-size: 13px;
+ color: #595959;
+ line-height: 1.5;
+ margin-bottom: 16px;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ height: 60px;
+}
+
+.postFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: auto;
+ padding-top: 12px;
+ border-top: 1px solid #f0f0f0;
+ height: 32px;
+}
+
+.stats {
+ display: flex;
+ gap: 16px;
+}
+
+.statItem {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: #8c8c8c;
+}
+
+.statItem .anticon {
+ font-size: 12px;
+}
+
+.readMoreBtn {
+ padding: 0;
+ font-size: 12px;
+ color: #1890ff;
+ font-weight: 500;
+}
+
+.readMoreBtn:hover {
+ color: #40a9ff;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .coverContainer {
+ height: 160px;
+ }
+
+ .postTitle {
+ font-size: 14px;
+ height: 40px;
+ }
+
+ .postSummary {
+ font-size: 12px;
+ -webkit-line-clamp: 2;
+ height: 36px;
+ }
+
+ .stats {
+ gap: 12px;
+ }
+
+ .statItem {
+ font-size: 11px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/PostCenter/PostCard.tsx b/src/pages/PostCenter/PostCard.tsx
new file mode 100644
index 0000000..947ecc3
--- /dev/null
+++ b/src/pages/PostCenter/PostCard.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Card, Tag, Button, Avatar, Badge } from 'antd';
+import { EyeOutlined, CommentOutlined, UserOutlined, ClockCircleOutlined, HeartOutlined, CrownOutlined } from '@ant-design/icons';
+import { history } from 'umi';
+import styles from './PostCard.module.css';
+import { Post } from '../PostCenter/types';
+
+interface PostCardProps {
+ post: Post;
+}
+
+const PostCard: React.FC<PostCardProps> = ({ post }) => {
+ const {
+ id,
+ title,
+ author,
+ publishTime,
+ tags,
+ views,
+ comments,
+ favorites,
+ likes,
+ coverImage,
+ summary,
+ promotionPlanId,
+ isPromoted
+ } = post;
+
+ const goToDetail = () => {
+ history.push(`/post-detail/${id}`);
+ };
+
+ return (
+ <div className={styles.postCardWrapper}>
+ <Card
+ hoverable
+ cover={
+ <div className={styles.coverContainer}>
+ {isPromoted && (
+ <div className={styles.promotionBadge}>
+ <CrownOutlined />
+ <span>推广</span>
+ </div>
+ )}
+ <img
+ alt={title}
+ src={coverImage}
+ className={styles.coverImage}
+ onError={(e) => {
+ e.currentTarget.src = '/images/404.png';
+ }}
+ />
+ </div>
+ }
+ className={styles.postCard}
+ bodyStyle={{ padding: '16px', height: '240px', display: 'flex', flexDirection: 'column' }}
+ >
+ <div className={styles.cardContent}>
+ <h3 className={styles.postTitle} title={title}>{title}</h3>
+
+ <div className={styles.postMeta}>
+ <Avatar size="small" style={{ marginRight: 6 }} icon={<UserOutlined />}>
+ {author && author[0]}
+ </Avatar>
+ <span className={styles.authorName}>{author}</span>
+ <ClockCircleOutlined style={{ marginLeft: 12, marginRight: 4 }} />
+ <span className={styles.publishTime}>{publishTime}</span>
+ </div>
+
+ <div className={styles.tagsContainer}>
+ {(Array.isArray(tags) ? tags : []).slice(0, 3).map(tag => (
+ <Tag color="blue" key={tag} className={styles.tag}>{tag}</Tag>
+ ))}
+ {tags && tags.length > 3 && (
+ <Tag color="default" className={styles.tag}>+{tags.length - 3}</Tag>
+ )}
+ </div>
+
+ <div className={styles.postSummary} title={summary}>{summary}</div>
+
+ <div className={styles.postFooter}>
+ <div className={styles.stats}>
+ <span className={styles.statItem}>
+ <EyeOutlined /> {views || 0}
+ </span>
+ <span className={styles.statItem}>
+ <CommentOutlined /> {comments || 0}
+ </span>
+ <span className={styles.statItem}>
+ <HeartOutlined /> {favorites || 0}
+ </span>
+ </div>
+ <Button
+ type="link"
+ className={styles.readMoreBtn}
+ onClick={goToDetail}
+ >
+ 查看更多 »
+ </Button>
+ </div>
+ </div>
+ </Card>
+ </div>
+ );
+};
+
+export default PostCard;
\ No newline at end of file
diff --git a/src/pages/PostCenter/PostDetail.module.css b/src/pages/PostCenter/PostDetail.module.css
new file mode 100644
index 0000000..da072d1
--- /dev/null
+++ b/src/pages/PostCenter/PostDetail.module.css
@@ -0,0 +1,233 @@
+.postDetailContainer {
+ max-width: 1200px;
+ margin: 0 auto;
+ background: #f5f5f5;
+ min-height: 100vh;
+}
+
+.postCoverSection {
+ margin-bottom: 24px;
+ border-radius: 0 0 16px 16px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.coverImageContainer {
+ position: relative;
+ width: 100%;
+ height: 400px;
+ overflow: hidden;
+ background: #f0f0f0;
+}
+
+.coverImage {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: cover !important;
+ transition: transform 0.3s ease;
+}
+
+.coverImage:hover {
+ transform: scale(1.02);
+}
+
+.coverOverlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+}
+
+.coverGradient {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 100px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.3));
+}
+
+.previewMask {
+ background: rgba(0, 0, 0, 0.5);
+ color: white;
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.postDetailHeader {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.titleContainer {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.titleContainer h2 {
+ margin: 0;
+ flex: 1;
+}
+
+.promotionBadge {
+ background: linear-gradient(45deg, #ff6b6b, #ffa500);
+ color: white;
+ padding: 6px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ white-space: nowrap;
+}
+
+.postMeta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+ margin: 16px 0;
+ padding: 16px 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.postAuthor,
+.postTime,
+.postViews,
+.postTags {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.postActions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+
+.postContent {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.postContent img {
+ max-width: 100%;
+ height: auto;
+}
+
+.commentSection {
+ margin: 0 24px 24px;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.commentInput {
+ margin-bottom: 24px;
+}
+
+.commentActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.commentList {
+ margin-top: 24px;
+}
+
+.replyList {
+ margin-top: 16px;
+ padding-left: 24px;
+ border-left: 2px solid #f0f0f0;
+}
+
+.relatedPosts {
+ margin: 0 24px 24px;
+ padding: 24px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.recommendHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+}
+
+.recommendHeader h4 {
+ margin: 0;
+}
+
+.postDetailLoading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ color: #666;
+}
+
+.postDetailError {
+ text-align: center;
+ padding: 60px 24px;
+ color: #999;
+ font-size: 16px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .postDetailContainer {
+ margin: 0;
+ }
+
+ .coverImageContainer {
+ height: 250px;
+ }
+
+ .postDetailHeader,
+ .postContent,
+ .commentSection,
+ .relatedPosts {
+ margin: 0 12px 16px;
+ border-radius: 8px;
+ }
+
+ .postMeta {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .postActions {
+ flex-direction: column;
+ }
+
+ .postActions button {
+ width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .coverImageContainer {
+ height: 200px;
+ }
+
+ .postDetailHeader,
+ .postContent,
+ .commentSection,
+ .relatedPosts {
+ margin: 0 8px 12px;
+ }
+}
\ No newline at end of file
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
diff --git a/src/pages/PostCenter/index.module.css b/src/pages/PostCenter/index.module.css
new file mode 100644
index 0000000..2b262e2
--- /dev/null
+++ b/src/pages/PostCenter/index.module.css
@@ -0,0 +1,189 @@
+.postCenterContainer {
+ min-height: 100vh;
+ background-color: #f5f5f5;
+}
+
+.headerNav {
+ background: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ padding: 0 24px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 64px;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.categoryMenu {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.categoryButton {
+ border: none;
+ box-shadow: none;
+ font-weight: 500;
+ padding: 8px 16px;
+ border-radius: 6px;
+ transition: all 0.3s ease;
+}
+
+.categoryButton:hover {
+ background-color: #f0f0f0;
+}
+
+.searchContainer {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ max-width: 400px;
+ margin: 0 24px;
+}
+
+.userCenter {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.carouselContainer {
+ margin: 24px;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.carouselSlide {
+ position: relative;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+}
+
+.carouselOverlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
+ color: white;
+ padding: 40px 40px 24px;
+ text-align: left;
+}
+
+.carouselTitle {
+ font-size: 28px;
+ font-weight: bold;
+ margin: 0 0 12px 0;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
+}
+
+.carouselSummary {
+ font-size: 16px;
+ margin: 0 0 16px 0;
+ opacity: 0.9;
+ line-height: 1.5;
+}
+
+.carouselMeta {
+ display: flex;
+ gap: 24px;
+ font-size: 14px;
+ opacity: 0.8;
+}
+
+.postsSection {
+ padding: 24px;
+}
+
+.categoryTitle {
+ margin-bottom: 24px;
+ text-align: center;
+}
+
+.categoryTitle h2 {
+ font-size: 24px;
+ margin: 0 0 8px 0;
+ color: #1890ff;
+}
+
+.categoryTitle p {
+ margin: 0;
+ color: #666;
+ font-size: 14px;
+}
+
+.postsRow {
+ margin-bottom: 32px;
+}
+
+.postCol {
+ display: flex;
+ height: 480px; /* 固定卡片高度 */
+}
+
+.postCol > * {
+ width: 100%;
+}
+
+.emptyState {
+ text-align: center;
+ padding: 60px 0;
+ color: #999;
+ font-size: 16px;
+}
+
+.paginationContainer {
+ display: flex;
+ justify-content: center;
+ margin-top: 32px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .headerNav {
+ flex-direction: column;
+ height: auto;
+ padding: 12px;
+ gap: 12px;
+ }
+
+ .categoryMenu {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .searchContainer {
+ margin: 0;
+ max-width: 100%;
+ }
+
+ .carouselContainer {
+ margin: 12px;
+ }
+
+ .carouselOverlay {
+ padding: 20px;
+ }
+
+ .carouselTitle {
+ font-size: 20px;
+ }
+
+ .carouselSummary {
+ font-size: 14px;
+ }
+
+ .carouselMeta {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .postsSection {
+ padding: 12px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/PostCenter/index.tsx b/src/pages/PostCenter/index.tsx
new file mode 100644
index 0000000..b81efb0
--- /dev/null
+++ b/src/pages/PostCenter/index.tsx
@@ -0,0 +1,301 @@
+import React, { useEffect, useState } from 'react';
+import { Row, Col, Pagination, Input, Carousel, Menu, Card, message, Button } from 'antd';
+import { SearchOutlined, AuditOutlined } from '@ant-design/icons';
+import { getPostList, getPromotionPosts } from '@/services/post';
+import PostCard from '../PostCenter/PostCard';
+import styles from './index.module.css';
+import { Post } from '../PostCenter/types';
+import { useNavigate } from 'react-router-dom';
+import { useModel } from 'umi';
+
+const { Search } = Input;
+
+const PostCenter: React.FC = () => {
+ const [posts, setPosts] = useState<Post[]>([]);
+ const [promotionPosts, setPromotionPosts] = useState<Post[]>([]);
+ const [total, setTotal] = useState<number>(0);
+ const [page, setPage] = useState<number>(1);
+ const [loading, setLoading] = useState<boolean>(false);
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
+ const [searchKeyword, setSearchKeyword] = useState<string>('');
+ const pageSize = 12;
+ const navigate = useNavigate();
+ const { initialState } = useModel('@@initialState');
+
+ // 检查是否为管理员 - 用户名包含admin
+ const isAdmin = initialState?.currentUser?.userName?.toLowerCase().includes('admin') || false;
+
+ const fetchPosts = async (current: number = 1, category?: string, searchTitle?: string) => {
+ try {
+ setLoading(true);
+ const params: any = {
+ pageNum: current,
+ pageSize: pageSize,
+ status: '1', // 只查询正常状态的帖子
+ };
+
+ // 根据分类筛选
+ if (category && category !== 'all') {
+ params.tags = category;
+ }
+
+ // 搜索关键词
+ if (searchTitle) {
+ params.title = searchTitle;
+ }
+
+ const response = await getPostList(params);
+
+ if (response.code === 200) {
+ // 确保返回的数据符合 Post 类型
+ const formattedPosts = (response.rows || []).map((post: API.Post.PostInfo) => ({
+ ...post,
+ id: post.postId, // 确保id字段映射正确
+ tags: post.tags ? post.tags.split(',') : [],
+ views: post.views || 0,
+ comments: post.comments || 0,
+ favorites: post.favorites || 0,
+ likes: post.likes || 0,
+ coverImage: post.coverImage || '/images/404.png', // 使用本地默认图片
+ isPromoted: post.promotionPlanId != null && post.promotionPlanId > 0, // 添加推广标识
+ }));
+ setPosts(formattedPosts);
+ setTotal(response.total || 0);
+ } else {
+ message.error(response.msg || '获取帖子列表失败');
+ setPosts([]);
+ setTotal(0);
+ }
+ } catch (error) {
+ console.error('获取帖子失败:', error);
+ message.error('获取帖子列表失败,请稍后重试');
+ setPosts([]);
+ setTotal(0);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchPromotionPosts = async () => {
+ try {
+ const response = await getPromotionPosts();
+ if (response.code === 200) {
+ const formattedPosts = (response.data || []).map((post: API.Post.PostInfo) => ({
+ ...post,
+ id: post.postId,
+ tags: post.tags ? post.tags.split(',') : [],
+ views: post.views || 0,
+ comments: post.comments || 0,
+ favorites: post.favorites || 0,
+ likes: post.likes || 0,
+ coverImage: post.coverImage || '/images/404.png',
+ isPromoted: post.promotionPlanId != null && post.promotionPlanId > 0, // 添加推广标识
+ }));
+ setPromotionPosts(formattedPosts);
+ }
+ } catch (error) {
+ console.error('获取推广帖子失败:', error);
+ }
+ };
+
+ useEffect(() => {
+ fetchPosts(page, selectedCategory, searchKeyword);
+ fetchPromotionPosts();
+ }, [page, selectedCategory]);
+
+ const handleSearch = (value: string) => {
+ console.log('搜索:', value);
+ setSearchKeyword(value);
+ setPage(1); // 重置页码
+ fetchPosts(1, selectedCategory, value);
+ };
+
+ const handlePageChange = (newPage: number) => {
+ setPage(newPage);
+ fetchPosts(newPage, selectedCategory, searchKeyword);
+ };
+
+ const handleCategoryChange = (category: string) => {
+ setSelectedCategory(category);
+ setPage(1); // 重置页码
+ setSearchKeyword(''); // 清空搜索
+ fetchPosts(1, category, '');
+ };
+
+ return (
+ <div className={styles.postCenterContainer}>
+ {/* 顶部导航 */}
+ <div className={styles.headerNav}>
+ <div className={styles.categoryMenu}>
+ <Button
+ type={selectedCategory === 'all' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('all')}
+ className={styles.categoryButton}
+ >
+ 首页
+ </Button>
+ <Button
+ type={selectedCategory === '日剧' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('日剧')}
+ className={styles.categoryButton}
+ >
+ 日剧
+ </Button>
+ <Button
+ type={selectedCategory === '电影' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('电影')}
+ className={styles.categoryButton}
+ >
+ 电影
+ </Button>
+ <Button
+ type={selectedCategory === '音乐' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('音乐')}
+ className={styles.categoryButton}
+ >
+ 音乐
+ </Button>
+ <Button
+ type={selectedCategory === '合集' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('合集')}
+ className={styles.categoryButton}
+ >
+ 合集
+ </Button>
+ <Button
+ type={selectedCategory === '动漫' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('动漫')}
+ className={styles.categoryButton}
+ >
+ 动漫
+ </Button>
+ <Button
+ type={selectedCategory === '游戏' ? 'primary' : 'text'}
+ onClick={() => handleCategoryChange('游戏')}
+ className={styles.categoryButton}
+ >
+ 游戏
+ </Button>
+ </div>
+
+ <div className={styles.searchContainer}>
+ <Search
+ placeholder="搜索帖子..."
+ onSearch={handleSearch}
+ style={{ width: 300 }}
+ enterButton={<SearchOutlined />}
+ value={searchKeyword}
+ onChange={(e) => setSearchKeyword(e.target.value)}
+ />
+ </div>
+
+ <div className={styles.userCenter}>
+ {isAdmin && (
+ <Button
+ icon={<AuditOutlined />}
+ onClick={() => navigate('/post-review')}
+ style={{ marginRight: 16 }}
+ >
+ 帖子审核
+ </Button>
+ )}
+ <Button
+ type="primary"
+ onClick={() => navigate('/user-center')}
+ >
+ 个人中心
+ </Button>
+ </div>
+ </div>
+
+ {/* 轮播推荐图 */}
+ <div className={styles.carouselContainer}>
+ <Carousel autoplay>
+ {promotionPosts.length > 0 ? (
+ promotionPosts.map((post) => (
+ <div key={post.id} onClick={() => navigate(`/post-detail/${post.id}`)}>
+ <div
+ className={styles.carouselSlide}
+ style={{
+ backgroundImage: `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), url(${post.coverImage})`,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ height: '300px',
+ position: 'relative',
+ cursor: 'pointer'
+ }}
+ >
+ <div className={styles.carouselOverlay}>
+ <h2 className={styles.carouselTitle}>{post.title}</h2>
+ <p className={styles.carouselSummary}>{post.summary}</p>
+ <div className={styles.carouselMeta}>
+ <span>作者: {post.author}</span>
+ <span>浏览: {post.views}</span>
+ <span>点赞: {post.likes}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ // 默认轮播图
+ <>
+ <div>
+ <div
+ className={styles.carouselSlide}
+ style={{
+ backgroundImage: `url(/images/flower.jpg)`,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ height: '300px',
+ }}
+ >
+ <div className={styles.carouselOverlay}>
+ <h2 className={styles.carouselTitle}>欢迎来到ThunderHub</h2>
+ <p className={styles.carouselSummary}>发现精彩内容,分享美好时光</p>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+ </Carousel>
+ </div>
+
+ {/* 卡片帖子区 */}
+ <div className={styles.postsSection}>
+ {selectedCategory !== 'all' && (
+ <div className={styles.categoryTitle}>
+ <h2>{selectedCategory} 分类</h2>
+ <p>共找到 {total} 篇相关帖子</p>
+ </div>
+ )}
+
+ <Row gutter={[24, 24]} className={styles.postsRow}>
+ {posts.map((post) => (
+ <Col xs={24} sm={12} lg={8} xl={6} key={post.id} className={styles.postCol}>
+ <PostCard post={post} />
+ </Col>
+ ))}
+ </Row>
+
+ {posts.length === 0 && !loading && (
+ <div className={styles.emptyState}>
+ <p>暂无相关帖子</p>
+ </div>
+ )}
+
+ <div className={styles.paginationContainer}>
+ <Pagination
+ current={page}
+ pageSize={pageSize}
+ total={total}
+ onChange={handlePageChange}
+ showTotal={(total) => `共 ${total} 条帖子`}
+ />
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default PostCenter;
\ No newline at end of file
diff --git a/src/pages/PostCenter/types.ts b/src/pages/PostCenter/types.ts
new file mode 100644
index 0000000..4cfe237
--- /dev/null
+++ b/src/pages/PostCenter/types.ts
@@ -0,0 +1,55 @@
+export interface Post {
+ postId?: number;
+ id: string | number;
+ title: string;
+ author: string;
+ publishTime: string;
+ tags: string[];
+ views: number;
+ comments?: number;
+ favorites?: number;
+ likes?: number;
+ coverImage?: string;
+ summary?: string;
+ content?: string;
+ status?: string;
+ authorId?: number;
+ createTime?: string;
+ updateTime?: string;
+ promotionPlanId?: number;
+ isPromoted?: boolean;
+}
+
+export interface CommentType {
+ commentId?: number;
+ id: number;
+ author: string;
+ avatar: string;
+ content: string;
+ datetime: string;
+ replies: CommentType[];
+ likes?: number;
+ parentId?: number;
+ postId?: number;
+ userId?: number;
+ userName?: string;
+ userAvatar?: string;
+ status?: string;
+}
+
+export interface PostTag {
+ tagId: number;
+ tagName: string;
+ tagColor?: string;
+ postCount: number;
+ status?: string;
+}
+
+export interface PostFavorite {
+ favoriteId: number;
+ postId: number;
+ userId: number;
+ postTitle?: string;
+ postCover?: string;
+ status: string;
+}
\ No newline at end of file