ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 1 | import React, { useState, useEffect } from 'react'; |
| 2 | import { useParams, useNavigate } from 'react-router-dom'; |
| 3 | import { |
| 4 | Card, |
| 5 | Avatar, |
| 6 | Typography, |
| 7 | Divider, |
| 8 | List, |
| 9 | Form, |
| 10 | Input, |
| 11 | Button, |
| 12 | message, |
| 13 | Spin, |
| 14 | Space, |
| 15 | Tag, |
ybt | 0d010e5 | 2025-06-09 00:29:36 +0800 | [diff] [blame^] | 16 | Empty, |
| 17 | Modal |
ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 18 | } from 'antd'; |
ybt | 0d010e5 | 2025-06-09 00:29:36 +0800 | [diff] [blame^] | 19 | import { ArrowLeftOutlined, MessageOutlined, UserOutlined, CommentOutlined, DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; |
| 20 | import { getComments, addComment, deleteComment } from '@/api/forum'; |
ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 21 | import { useAuth } from '@/features/auth/contexts/AuthContext'; |
| 22 | |
| 23 | const { Title, Paragraph, Text } = Typography; |
| 24 | const { TextArea } = Input; |
| 25 | |
| 26 | const PostDetailPage = () => { |
| 27 | const { postId } = useParams(); |
| 28 | const navigate = useNavigate(); |
ybt | 0d010e5 | 2025-06-09 00:29:36 +0800 | [diff] [blame^] | 29 | const { user, isAuthenticated, hasRole } = useAuth(); |
| 30 | |
| 31 | // 判断是否为管理员 |
| 32 | const isAdmin = hasRole('admin') || (user && user.uid && user.uid.includes('admin')); |
ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 33 | const [loading, setLoading] = useState(true); |
| 34 | const [commenting, setCommenting] = useState(false); |
| 35 | const [postContent, setPostContent] = useState(''); |
| 36 | const [comments, setComments] = useState([]); |
| 37 | const [form] = Form.useForm(); |
| 38 | const [replyForms] = Form.useForm(); // 用于回复的表单 |
| 39 | const [replyingTo, setReplyingTo] = useState(null); // 当前正在回复的评论ID |
| 40 | const [replying, setReplying] = useState(false); // 回复中状态 |
| 41 | |
| 42 | // 获取帖子详情和评论 |
| 43 | useEffect(() => { |
| 44 | if (isAuthenticated && user?.username && postId) { |
| 45 | fetchPostAndComments(); |
| 46 | } |
| 47 | }, [isAuthenticated, user, postId]); |
| 48 | |
| 49 | // 监听ESC键取消回复 |
| 50 | useEffect(() => { |
| 51 | const handleKeyDown = (event) => { |
| 52 | if (event.key === 'Escape' && replyingTo) { |
| 53 | cancelReply(); |
| 54 | } |
| 55 | }; |
| 56 | |
| 57 | document.addEventListener('keydown', handleKeyDown); |
| 58 | return () => { |
| 59 | document.removeEventListener('keydown', handleKeyDown); |
| 60 | }; |
| 61 | }, [replyingTo]); |
| 62 | |
| 63 | const fetchPostAndComments = async () => { |
| 64 | try { |
| 65 | setLoading(true); |
| 66 | |
| 67 | const params = { |
| 68 | postId: postId, |
| 69 | username: user.username |
| 70 | } |
| 71 | const response = await getComments(params); |
| 72 | |
| 73 | if (response && response.data) { |
| 74 | setPostContent(response.data.content || ''); |
| 75 | // 直接按发布时间排序,最新的在前面 |
| 76 | const allComments = response.data.comments || []; |
| 77 | const sortedComments = allComments.sort((a, b) => |
| 78 | new Date(b.publishDate) - new Date(a.publishDate) |
| 79 | ); |
| 80 | setComments(sortedComments); |
| 81 | } else { |
| 82 | message.error('获取帖子详情失败'); |
| 83 | } |
| 84 | } catch (error) { |
| 85 | console.error('获取帖子详情失败:', error); |
| 86 | message.error(error.message || '获取帖子详情失败'); |
| 87 | } finally { |
| 88 | setLoading(false); |
| 89 | } |
| 90 | }; |
| 91 | |
| 92 | // 提交主评论 |
| 93 | const handleSubmitComment = async () => { |
| 94 | try { |
| 95 | const values = await form.validateFields(); |
| 96 | setCommenting(true); |
| 97 | |
| 98 | const params = { |
| 99 | content: values.comment, |
| 100 | username: user.username, |
| 101 | postId: postId |
| 102 | }; |
| 103 | |
| 104 | console.log('提交评论数据:', params); |
| 105 | const response = await addComment(params); |
| 106 | |
| 107 | if (response && response.message === 'Comment added successfully') { |
| 108 | message.success('评论发布成功'); |
| 109 | form.resetFields(); |
| 110 | fetchPostAndComments(); |
| 111 | } else { |
| 112 | message.error('评论发布失败'); |
| 113 | } |
| 114 | } catch (error) { |
| 115 | console.error('发布评论失败:', error); |
| 116 | message.error(error.message || '发布评论失败'); |
| 117 | } finally { |
| 118 | setCommenting(false); |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | // 提交回复评论 |
| 123 | const handleSubmitReply = async (reviewerId) => { |
| 124 | try { |
| 125 | const values = await replyForms.validateFields(); |
| 126 | setReplying(true); |
| 127 | |
| 128 | const replyData = { |
| 129 | content: values.reply, |
| 130 | username: user.username, |
| 131 | postId: postId, |
| 132 | reviewer: reviewerId |
| 133 | }; |
| 134 | |
| 135 | console.log('提交回复数据:', replyData); |
| 136 | const response = await addComment(replyData); |
| 137 | |
| 138 | if (response && response.message === 'Comment added successfully') { |
| 139 | message.success('回复发布成功'); |
| 140 | replyForms.resetFields(); |
| 141 | setReplyingTo(null); |
| 142 | fetchPostAndComments(); |
| 143 | } else { |
| 144 | message.error('回复发布失败'); |
| 145 | } |
| 146 | } catch (error) { |
| 147 | console.error('发布回复失败:', error); |
| 148 | message.error(error.message || '发布回复失败'); |
| 149 | } finally { |
| 150 | setReplying(false); |
| 151 | } |
| 152 | }; |
| 153 | |
| 154 | // 开始回复评论 |
| 155 | const startReply = (commentId) => { |
| 156 | setReplyingTo(commentId); |
| 157 | replyForms.resetFields(); |
| 158 | }; |
| 159 | |
| 160 | // 取消回复 |
| 161 | const cancelReply = () => { |
| 162 | setReplyingTo(null); |
| 163 | replyForms.resetFields(); |
| 164 | }; |
| 165 | |
ybt | 0d010e5 | 2025-06-09 00:29:36 +0800 | [diff] [blame^] | 166 | // 删除评论(管理员功能) |
| 167 | const handleDeleteComment = (comment) => { |
| 168 | Modal.confirm({ |
| 169 | title: '确认删除评论', |
| 170 | icon: <ExclamationCircleOutlined />, |
| 171 | content: ( |
| 172 | <div> |
| 173 | <p>您确定要删除这条评论吗?</p> |
| 174 | <p><strong>作者:</strong>{comment.writer}</p> |
| 175 | <p><strong>内容:</strong>{comment.content.length > 50 ? comment.content.substring(0, 50) + '...' : comment.content}</p> |
| 176 | <p className="text-red-500 mt-2">此操作不可撤销!</p> |
| 177 | </div> |
| 178 | ), |
| 179 | okText: '确认删除', |
| 180 | okType: 'danger', |
| 181 | cancelText: '取消', |
| 182 | async onOk() { |
| 183 | try { |
| 184 | const params = { |
| 185 | username: user.username, |
| 186 | commentId: comment.commentId |
| 187 | }; |
| 188 | const response = await deleteComment(params); |
| 189 | if (response.message) { |
| 190 | message.success(response.message || '评论删除成功'); |
| 191 | fetchPostAndComments(); // 重新加载评论 |
| 192 | } else { |
| 193 | message.error(response.message || '删除评论失败'); |
| 194 | } |
| 195 | } catch (error) { |
| 196 | console.error('删除评论失败:', error); |
| 197 | message.error(error.message || '删除评论失败'); |
| 198 | } |
| 199 | }, |
| 200 | }); |
| 201 | }; |
| 202 | |
ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 203 | // 格式化日期 |
| 204 | const formatDate = (dateString) => { |
| 205 | try { |
| 206 | return new Date(dateString).toLocaleString('zh-CN', { |
| 207 | year: 'numeric', |
| 208 | month: '2-digit', |
| 209 | day: '2-digit', |
| 210 | hour: '2-digit', |
| 211 | minute: '2-digit' |
| 212 | }); |
| 213 | } catch { |
| 214 | return dateString; |
| 215 | } |
| 216 | }; |
| 217 | |
| 218 | // 查找被回复的评论 |
| 219 | const getReviewedComment = (reviewerId) => { |
| 220 | return comments.find(comment => comment.commentId === reviewerId); |
| 221 | }; |
| 222 | |
| 223 | // 如果用户未认证 |
| 224 | if (!isAuthenticated) { |
| 225 | return ( |
| 226 | <div className="text-center py-8"> |
| 227 | <Title level={3}>请先登录</Title> |
| 228 | <Paragraph>您需要登录后才能查看帖子详情</Paragraph> |
| 229 | </div> |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | return ( |
| 234 | <div className="max-w-4xl mx-auto space-y-6"> |
| 235 | {/* 返回按钮 */} |
| 236 | <Button |
| 237 | icon={<ArrowLeftOutlined />} |
| 238 | onClick={() => navigate('/forum')} |
| 239 | className="mb-4" |
| 240 | > |
| 241 | 返回论坛 |
| 242 | </Button> |
| 243 | |
| 244 | {loading ? ( |
| 245 | <div className="flex justify-center py-8"> |
| 246 | <Spin size="large" tip="加载中..." /> |
| 247 | </div> |
| 248 | ) : ( |
| 249 | <> |
| 250 | {/* 帖子内容 */} |
| 251 | <Card title={ |
| 252 | <Space> |
| 253 | <MessageOutlined /> |
| 254 | <span>帖子详情</span> |
| 255 | </Space> |
| 256 | }> |
| 257 | <div className="mb-4"> |
| 258 | <Title level={4}>帖子内容</Title> |
| 259 | <Paragraph style={{ fontSize: '16px', lineHeight: 1.6 }}> |
| 260 | {postContent || '暂无内容'} |
| 261 | </Paragraph> |
| 262 | </div> |
| 263 | <Divider /> |
| 264 | <div className="flex justify-between items-center text-sm text-gray-500"> |
| 265 | <span>帖子ID: {postId}</span> |
| 266 | <Space> |
| 267 | <Tag color="blue"> |
| 268 | <MessageOutlined /> {comments.length} 条评论 |
| 269 | </Tag> |
| 270 | </Space> |
| 271 | </div> |
| 272 | </Card> |
| 273 | |
| 274 | {/* 评论区 */} |
| 275 | <Card title={ |
| 276 | <Space> |
| 277 | <MessageOutlined /> |
| 278 | <span>评论区 ({comments.length})</span> |
| 279 | </Space> |
| 280 | }> |
| 281 | {/* 发表评论 */} |
| 282 | <div className="mb-6"> |
| 283 | <Title level={5}>发表评论</Title> |
| 284 | <Form form={form} layout="vertical"> |
| 285 | <Form.Item |
| 286 | name="comment" |
| 287 | rules={[{ required: true, message: '请输入评论内容' }]} |
| 288 | > |
| 289 | <TextArea |
| 290 | rows={4} |
| 291 | placeholder="请输入您的评论..." |
| 292 | maxLength={500} |
| 293 | showCount |
| 294 | /> |
| 295 | </Form.Item> |
| 296 | <Form.Item> |
| 297 | <Button |
| 298 | type="primary" |
| 299 | onClick={handleSubmitComment} |
| 300 | loading={commenting} |
| 301 | > |
| 302 | 发布评论 |
| 303 | </Button> |
| 304 | </Form.Item> |
| 305 | </Form> |
| 306 | </div> |
| 307 | |
| 308 | <Divider /> |
| 309 | |
| 310 | {/* 评论列表 */} |
| 311 | {comments.length > 0 ? ( |
| 312 | <List |
| 313 | itemLayout="vertical" |
| 314 | dataSource={comments} |
| 315 | renderItem={(comment) => ( |
| 316 | <List.Item |
| 317 | key={comment.commentId} |
| 318 | className={`border-l-2 pl-4 hover:bg-gray-50 transition-colors ${ |
| 319 | comment.reviewerId ? 'border-l-orange-200 bg-orange-50' : 'border-l-blue-100' |
| 320 | }`} |
| 321 | > |
| 322 | <List.Item.Meta |
| 323 | avatar={ |
| 324 | <Avatar |
| 325 | src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${comment.writer || 'anonymous'}`} |
| 326 | icon={<UserOutlined />} |
| 327 | /> |
| 328 | } |
| 329 | title={ |
| 330 | <div className="flex flex-wrap items-center gap-2"> |
| 331 | <Text strong>{comment.writer || '匿名用户'}</Text> |
| 332 | <Text type="secondary" className="text-sm"> |
| 333 | {formatDate(comment.publishDate)} |
| 334 | </Text> |
| 335 | <Tag size="small" color="blue"> |
| 336 | #{comment.commentId} |
| 337 | </Tag> |
| 338 | {comment.reviewerId && ( |
| 339 | <Tag size="small" color="orange" className="ml-2"> |
| 340 | 回复 #{comment.reviewerId} |
| 341 | </Tag> |
| 342 | )} |
ybt | 0d010e5 | 2025-06-09 00:29:36 +0800 | [diff] [blame^] | 343 | {isAdmin && ( |
| 344 | <Button |
| 345 | type="text" |
| 346 | danger |
| 347 | size="small" |
| 348 | icon={<DeleteOutlined />} |
| 349 | onClick={() => handleDeleteComment(comment)} |
| 350 | title="删除评论(管理员)" |
| 351 | className="ml-2" |
| 352 | > |
| 353 | 删除 |
| 354 | </Button> |
| 355 | )} |
ybt | bac75f2 | 2025-06-08 22:31:15 +0800 | [diff] [blame] | 356 | </div> |
| 357 | } |
| 358 | description={ |
| 359 | <div> |
| 360 | {/* 显示被回复的评论 */} |
| 361 | {comment.reviewerId && ( |
| 362 | <div className="mb-3 p-3 bg-gray-100 rounded border-l-4 border-l-orange-400"> |
| 363 | <Text type="secondary" className="text-xs"> |
| 364 | 回复 #{comment.reviewerId}: |
| 365 | </Text> |
| 366 | {(() => { |
| 367 | const reviewedComment = getReviewedComment(comment.reviewerId); |
| 368 | return reviewedComment ? ( |
| 369 | <div className="mt-1"> |
| 370 | <Text type="secondary" className="text-sm"> |
| 371 | {reviewedComment.writer || '匿名用户'}: |
| 372 | </Text> |
| 373 | <Text className="text-sm ml-1"> |
| 374 | {reviewedComment.content.length > 50 |
| 375 | ? reviewedComment.content.substring(0, 50) + '...' |
| 376 | : reviewedComment.content} |
| 377 | </Text> |
| 378 | </div> |
| 379 | ) : ( |
| 380 | <Text type="secondary" className="text-sm"> |
| 381 | 原评论已被删除 |
| 382 | </Text> |
| 383 | ); |
| 384 | })()} |
| 385 | </div> |
| 386 | )} |
| 387 | |
| 388 | <Paragraph className="mt-2 mb-3"> |
| 389 | {comment.content} |
| 390 | </Paragraph> |
| 391 | |
| 392 | {/* 回复按钮 */} |
| 393 | <div className="mb-3"> |
| 394 | <Button |
| 395 | type="link" |
| 396 | icon={<CommentOutlined />} |
| 397 | onClick={() => startReply(comment.commentId)} |
| 398 | size="small" |
| 399 | disabled={replyingTo === comment.commentId} |
| 400 | className="p-0" |
| 401 | > |
| 402 | {replyingTo === comment.commentId ? '回复中...' : '回复'} |
| 403 | </Button> |
| 404 | </div> |
| 405 | |
| 406 | {/* 回复表单 */} |
| 407 | {replyingTo === comment.commentId && ( |
| 408 | <div className="mt-4 p-4 bg-gray-50 rounded-lg"> |
| 409 | <Form form={replyForms} layout="vertical"> |
| 410 | <Form.Item |
| 411 | name="reply" |
| 412 | rules={[{ required: true, message: '请输入回复内容' }]} |
| 413 | > |
| 414 | <TextArea |
| 415 | rows={3} |
| 416 | placeholder={`回复 ${comment.writer || '匿名用户'}...`} |
| 417 | maxLength={500} |
| 418 | showCount |
| 419 | /> |
| 420 | </Form.Item> |
| 421 | <Form.Item className="mb-0"> |
| 422 | <Space> |
| 423 | <Button |
| 424 | type="primary" |
| 425 | size="small" |
| 426 | onClick={() => handleSubmitReply(comment.commentId)} |
| 427 | loading={replying} |
| 428 | > |
| 429 | 发布回复 |
| 430 | </Button> |
| 431 | <Button |
| 432 | size="small" |
| 433 | onClick={cancelReply} |
| 434 | > |
| 435 | 取消 |
| 436 | </Button> |
| 437 | </Space> |
| 438 | </Form.Item> |
| 439 | </Form> |
| 440 | </div> |
| 441 | )} |
| 442 | </div> |
| 443 | } |
| 444 | /> |
| 445 | </List.Item> |
| 446 | )} |
| 447 | /> |
| 448 | ) : ( |
| 449 | <Empty |
| 450 | description="暂无评论,快来发表第一条评论吧!" |
| 451 | image={Empty.PRESENTED_IMAGE_SIMPLE} |
| 452 | /> |
| 453 | )} |
| 454 | </Card> |
| 455 | </> |
| 456 | )} |
| 457 | </div> |
| 458 | ); |
| 459 | }; |
| 460 | |
| 461 | export default PostDetailPage; |