blob: c6aedccfcedafeab8543bd191101806f285bed6a [file] [log] [blame]
meisiyu1d4aade2025-06-02 20:10:36 +08001import React, { useState, useEffect } from 'react';
2import { useParams, Link } from 'umi';
3import { Card, Avatar, Tag, Row, Col, Divider, List, Input, Button, Typography, message, Spin, Image, Modal, Badge, Pagination } from 'antd';
4import { Comment } from '@ant-design/compatible';
5import {
6 UserOutlined,
7 ClockCircleOutlined,
8 EyeOutlined,
9 TagOutlined,
10 LikeOutlined,
11 DislikeOutlined,
12 ShareAltOutlined,
13 HeartOutlined,
14 HeartFilled,
15 ExclamationCircleOutlined,
16 CrownOutlined
17} from '@ant-design/icons';
18import { getPostDetail, addComment, toggleFavorite, toggleLike, toggleCommentLike, reportPost } from '@/services/post';
19import PostCard from '../PostCenter/PostCard';
20import styles from './PostDetail.module.css';
21import { Post, CommentType } from '../PostCenter/types';
22
23const { TextArea } = Input;
24const { Title, Paragraph, Text } = Typography;
25
26const PostDetail: React.FC = () => {
27 const { id } = useParams<{ id: string }>();
28 const [post, setPost] = useState<Post | null>(null);
29 const [recommendedPosts, setRecommendedPosts] = useState<Post[]>([]);
30 const [currentRecommendPage, setCurrentRecommendPage] = useState<number>(1);
31 const [comments, setComments] = useState<CommentType[]>([]);
32 const [replyTo, setReplyTo] = useState<number | null>(null);
33 const [commentText, setCommentText] = useState<string>('');
34 const [loading, setLoading] = useState<boolean>(true);
35 const [favorited, setFavorited] = useState<boolean>(false);
36 const [liked, setLiked] = useState<boolean>(false);
37 const [submittingComment, setSubmittingComment] = useState<boolean>(false);
38 const [commentLikes, setCommentLikes] = useState<Record<number, boolean>>({});
39 const [reportModalVisible, setReportModalVisible] = useState<boolean>(false);
40 const [reportReason, setReportReason] = useState<string>('');
41 const [submittingReport, setSubmittingReport] = useState<boolean>(false);
42
43 const recommendPageSize = 3; // 每页显示3个推荐帖子
44
45 useEffect(() => {
46 if (id) {
47 fetchPostDetail(Number(id));
48 }
49 }, [id]);
50
51 const fetchPostDetail = async (postId: number) => {
52 try {
53 setLoading(true);
54 const response = await getPostDetail(postId);
55
56 if (response.code === 200 && response.data) {
57 const { post: postData, tags, comments: commentsData, recommendedPosts, favorited: isFavorited } = response.data;
58
59 // 转换帖子数据格式
60 const formattedPost: Post = {
61 ...postData,
62 id: postData.postId,
63 title: postData.title || '无标题',
64 author: postData.author || '未知作者',
65 publishTime: postData.publishTime || postData.createTime || '',
66 tags: postData.tags ? postData.tags.split(',') : [],
67 views: postData.views || 0,
68 comments: postData.comments || 0,
69 favorites: postData.favorites || 0,
70 likes: postData.likes || 0,
71 coverImage: postData.coverImage || '/images/404.png',
72 summary: postData.summary || '暂无摘要',
73 isPromoted: postData.promotionPlanId != null && postData.promotionPlanId > 0,
74 };
75 setPost(formattedPost);
76 setFavorited(isFavorited);
77
78 // 转换评论数据格式
79 const formattedComments = commentsData.map((commentItem: any) => {
80 const comment = commentItem.comment;
81 const replies = commentItem.replies || [];
82
83 return {
84 ...comment,
85 id: comment.commentId,
86 author: comment.userName || '匿名用户',
87 avatar: comment.userAvatar || '/images/404.png',
88 datetime: comment.createTime,
89 likes: comment.likes || 0,
90 replies: replies.map((reply: any) => ({
91 ...reply,
92 id: reply.commentId,
93 author: reply.userName || '匿名用户',
94 avatar: reply.userAvatar || '/images/404.png',
95 datetime: reply.createTime,
96 likes: reply.likes || 0,
97 replies: []
98 }))
99 };
100 });
101 setComments(formattedComments);
102
103 // 转换推荐帖子数据格式
104 const formatPosts = (posts: any[]) => posts.map((p: any) => ({
105 ...p,
106 id: p.postId,
107 title: p.title || '无标题',
108 author: p.author || '未知作者',
109 publishTime: p.publishTime || p.createTime || '',
110 tags: p.tags ? p.tags.split(',') : [],
111 views: p.views || 0,
112 comments: p.comments || 0,
113 favorites: p.favorites || 0,
114 likes: p.likes || 0,
115 coverImage: p.coverImage || '/images/404.png',
116 summary: p.summary || '暂无摘要',
117 isPromoted: p.promotionPlanId != null && p.promotionPlanId > 0, // 添加推广标识
118 }));
119
120 // 设置推荐帖子
121 const formattedRecommendedPosts = formatPosts(recommendedPosts || []);
122 setRecommendedPosts(formattedRecommendedPosts);
123 } else {
124 message.error(response.msg || '获取帖子详情失败');
125 }
126 } catch (error) {
127 console.error('获取帖子详情失败:', error);
128 message.error('获取帖子详情失败,请稍后重试');
129 } finally {
130 setLoading(false);
131 }
132 };
133
134 const handleCommentSubmit = async () => {
135 if (!commentText.trim()) {
136 message.warning('评论内容不能为空');
137 return;
138 }
139
140 if (!post) return;
141
142 try {
143 setSubmittingComment(true);
144 const response = await addComment({
145 postId: Number(post.id),
146 content: commentText,
147 parentId: replyTo || 0,
148 });
149
150 if (response.code === 200) {
151 message.success('评论发表成功');
152 setCommentText('');
153 setReplyTo(null);
154 // 重新获取帖子详情以更新评论列表
155 fetchPostDetail(Number(post.id));
156 } else {
157 message.error(response.msg || '评论发表失败');
158 }
159 } catch (error) {
160 console.error('评论发表失败:', error);
161 message.error('评论发表失败,请稍后重试');
162 } finally {
163 setSubmittingComment(false);
164 }
165 };
166
167 const handleFavoriteToggle = async () => {
168 if (!post) return;
169
170 try {
171 const response = await toggleFavorite(Number(post.id), !favorited);
172
173 if (response.code === 200) {
174 setFavorited(!favorited);
175 message.success(favorited ? '取消收藏成功' : '收藏成功');
176 // 更新帖子收藏数
177 setPost(prev => prev ? {
178 ...prev,
179 favorites: (prev.favorites || 0) + (favorited ? -1 : 1)
180 } : null);
181 } else {
182 message.error(response.msg || '操作失败');
183 }
184 } catch (error) {
185 console.error('收藏操作失败:', error);
186 message.error('操作失败,请稍后重试');
187 }
188 };
189
190 const handleLikeToggle = async () => {
191 if (!post) return;
192
193 try {
194 const response = await toggleLike(Number(post.id), !liked);
195
196 if (response.code === 200) {
197 setLiked(!liked);
198 message.success(liked ? '取消点赞成功' : '点赞成功');
199 // 更新帖子点赞数
200 setPost(prev => prev ? {
201 ...prev,
202 likes: (prev.likes || 0) + (liked ? -1 : 1)
203 } : null);
204 } else {
205 message.error(response.msg || '操作失败');
206 }
207 } catch (error) {
208 console.error('点赞操作失败:', error);
209 message.error('操作失败,请稍后重试');
210 }
211 };
212
213 const handleReply = (commentId: number) => {
214 setReplyTo(commentId);
215 };
216
217 const cancelReply = () => {
218 setReplyTo(null);
219 };
220
221 const handleCommentLike = async (commentId: number) => {
222 try {
223 const isLiked = commentLikes[commentId] || false;
224 const response = await toggleCommentLike(commentId, !isLiked);
225
226 if (response.code === 200) {
227 setCommentLikes(prev => ({
228 ...prev,
229 [commentId]: !isLiked
230 }));
231
232 // 更新评论点赞数
233 setComments(prev => prev.map(comment => {
234 if (comment.id === commentId) {
235 return {
236 ...comment,
237 likes: (comment.likes || 0) + (isLiked ? -1 : 1)
238 };
239 }
240 // 检查回复
241 if (comment.replies) {
242 const updatedReplies = comment.replies.map(reply => {
243 if (reply.id === commentId) {
244 return {
245 ...reply,
246 likes: (reply.likes || 0) + (isLiked ? -1 : 1)
247 };
248 }
249 return reply;
250 });
251 return { ...comment, replies: updatedReplies };
252 }
253 return comment;
254 }));
255
256 message.success(isLiked ? '取消点赞成功' : '点赞成功');
257 } else {
258 message.error(response.msg || '操作失败');
259 }
260 } catch (error) {
261 console.error('评论点赞操作失败:', error);
262 message.error('操作失败,请稍后重试');
263 }
264 };
265
266 const handleReport = async () => {
267 if (!post) return;
268
269 if (!reportReason.trim()) {
270 message.warning('请填写举报理由');
271 return;
272 }
273
274 try {
275 setSubmittingReport(true);
276 const response = await reportPost(Number(post.id), reportReason);
277
278 if (response.code === 200) {
279 message.success('举报提交成功,我们会尽快处理');
280 setReportModalVisible(false);
281 setReportReason('');
282 } else {
283 message.error(response.msg || '举报提交失败');
284 }
285 } catch (error) {
286 console.error('举报提交失败:', error);
287 message.error('举报提交失败,请稍后重试');
288 } finally {
289 setSubmittingReport(false);
290 }
291 };
292
293 if (loading) {
294 return (
295 <div className={styles.postDetailLoading}>
296 <Spin size="large" />
297 <div style={{ marginTop: 16 }}>加载中...</div>
298 </div>
299 );
300 }
301
302 if (!post) {
303 return <div className={styles.postDetailError}>帖子不存在或已被删除</div>;
304 }
305
306 return (
307 <div className={styles.postDetailContainer}>
308 {/* 帖子封面图片 */}
309 {post.coverImage && (
310 <div className={styles.postCoverSection}>
311 <div className={styles.coverImageContainer}>
312 <Image
313 src={post.coverImage}
314 alt={post.title}
315 className={styles.coverImage}
316 preview={{
317 mask: <div className={styles.previewMask}>点击预览</div>
318 }}
319 onError={(e) => {
320 e.currentTarget.src = '/images/404.png';
321 }}
322 />
323 <div className={styles.coverOverlay}>
324 <div className={styles.coverGradient}></div>
325 </div>
326 </div>
327 </div>
328 )}
329
330 {/* 帖子头部信息 */}
331 <Card className={styles.postDetailHeader}>
332 <div className={styles.titleContainer}>
333 <Title level={2}>{post.title}</Title>
334 {post.isPromoted && (
335 <div className={styles.promotionBadge}>
336 <CrownOutlined />
337 <span>推广</span>
338 </div>
339 )}
340 </div>
341
342 <div className={styles.postMeta}>
343 <div className={styles.postAuthor}>
344 <Avatar size="small" icon={<UserOutlined />} />
345 <Text strong style={{ marginLeft: 8 }}>{post.author}</Text>
346 </div>
347
348 <div className={styles.postTime}>
349 <ClockCircleOutlined />
350 <Text type="secondary" style={{ marginLeft: 8 }}>{post.publishTime}</Text>
351 </div>
352
353 <div className={styles.postViews}>
354 <EyeOutlined />
355 <Text type="secondary" style={{ marginLeft: 8 }}>{post.views} 查看</Text>
356 </div>
357
358 <div className={styles.postTags}>
359 <TagOutlined />
360 <span style={{ marginLeft: 8 }}>
361 {post.tags.map(tag => (
362 <Tag key={tag} color="blue">{tag}</Tag>
363 ))}
364 </span>
365 </div>
366 </div>
367
368 {/* 操作按钮 */}
369 <div className={styles.postActions}>
370 <Button
371 type={favorited ? "primary" : "default"}
372 icon={favorited ? <HeartFilled /> : <HeartOutlined />}
373 onClick={handleFavoriteToggle}
374 >
375 {favorited ? '已收藏' : '收藏'} ({post.favorites || 0})
376 </Button>
377 <Button
378 type={liked ? "primary" : "default"}
379 icon={<LikeOutlined />}
380 onClick={handleLikeToggle}
381 >
382 {liked ? '已点赞' : '点赞'} ({post.likes || 0})
383 </Button>
384 {/* <Button icon={<ShareAltOutlined />}>
385 分享
386 </Button> */}
387 <Button
388 icon={<ExclamationCircleOutlined />}
389 onClick={() => setReportModalVisible(true)}
390 >
391 举报
392 </Button>
393 </div>
394 </Card>
395
396 {/* 帖子内容 */}
397 <Card className={styles.postContent}>
398 <div dangerouslySetInnerHTML={{ __html: post.content || post.summary || '' }} />
399 </Card>
400
401 {/* 评论区 */}
402 <Card className={styles.commentSection}>
403 <Title level={4}>评论 ({comments.length})</Title>
404
405 <div className={styles.commentInput}>
406 <TextArea
407 value={commentText}
408 onChange={e => setCommentText(e.target.value)}
409 placeholder={replyTo ? '回复评论...' : '写下你的评论...'}
410 rows={4}
411 />
412 <div className={styles.commentActions}>
413 {replyTo && (
414 <Button onClick={cancelReply}>取消回复</Button>
415 )}
416 <Button
417 type="primary"
418 onClick={handleCommentSubmit}
419 loading={submittingComment}
420 >
421 发表{replyTo ? '回复' : '评论'}
422 </Button>
423 </div>
424 </div>
425
426 <List
427 className={styles.commentList}
428 itemLayout="horizontal"
429 dataSource={comments}
430 renderItem={comment => (
431 <li>
432 <Comment
433 author={comment.author}
434 avatar={comment.avatar}
435 content={comment.content}
436 datetime={comment.datetime}
437 actions={[
438 <span key="like">
439 <Button
440 type={commentLikes[comment.id] ? "primary" : "text"}
441 size="small"
442 icon={<LikeOutlined />}
443 onClick={() => handleCommentLike(comment.id)}
444 >
445 {comment.likes || 0}
446 </Button>
447 </span>,
448 <span key="reply" onClick={() => handleReply(comment.id)}>回复</span>
449 ]}
450 >
451 {comment.replies && comment.replies.length > 0 && (
452 <List
453 className={styles.replyList}
454 itemLayout="horizontal"
455 dataSource={comment.replies}
456 renderItem={reply => (
457 <li>
458 <Comment
459 author={reply.author}
460 avatar={reply.avatar}
461 content={reply.content}
462 datetime={reply.datetime}
463 actions={[
464 <span key="like">
465 <Button
466 type={commentLikes[reply.id] ? "primary" : "text"}
467 size="small"
468 icon={<LikeOutlined />}
469 onClick={() => handleCommentLike(reply.id)}
470 >
471 {reply.likes || 0}
472 </Button>
473 </span>
474 ]}
475 />
476 </li>
477 )}
478 />
479 )}
480 </Comment>
481 </li>
482 )}
483 />
484 </Card>
485
486 {/* 举报弹窗 */}
487 <Modal
488 title="举报帖子"
489 open={reportModalVisible}
490 onOk={handleReport}
491 onCancel={() => {
492 setReportModalVisible(false);
493 setReportReason('');
494 }}
495 confirmLoading={submittingReport}
496 okText="提交举报"
497 cancelText="取消"
498 >
499 <div style={{ marginBottom: 16 }}>
500 <strong>帖子:</strong>{post?.title}
501 </div>
502 <div>
503 <strong>举报理由:</strong>
504 <TextArea
505 value={reportReason}
506 onChange={(e) => setReportReason(e.target.value)}
507 placeholder="请详细描述举报理由..."
508 rows={4}
509 style={{ marginTop: 8 }}
510 />
511 </div>
512 </Modal>
513
514 {/* 相关推荐 */}
515 {recommendedPosts.length > 0 && (
516 <div className={styles.relatedPosts}>
517 <div className={styles.recommendHeader}>
518 <Title level={4}>相关推荐</Title>
519 {recommendedPosts.length > recommendPageSize && (
520 <Pagination
521 current={currentRecommendPage}
522 total={recommendedPosts.length}
523 pageSize={recommendPageSize}
524 onChange={(page) => setCurrentRecommendPage(page)}
525 showSizeChanger={false}
526 showQuickJumper={false}
527 showTotal={(total, range) => `${range[0]}-${range[1]} / ${total}`}
528 size="small"
529 />
530 )}
531 </div>
532 <Row gutter={[24, 24]}>
533 {recommendedPosts
534 .slice((currentRecommendPage - 1) * recommendPageSize, currentRecommendPage * recommendPageSize)
535 .map(post => (
536 <Col xs={24} sm={12} md={8} key={post.id}>
537 <PostCard post={post} />
538 </Col>
539 ))}
540 </Row>
541 </div>
542 )}
543 </div>
544 );
545};
546
547export default PostDetail;