LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 1 | 'use client'; |
| 2 | |
| 3 | import React, { useEffect, useState, useRef } from 'react'; |
| 4 | import { Image } from 'primereact/image'; |
| 5 | import { Avatar } from 'primereact/avatar'; |
| 6 | import { Button } from 'primereact/button'; |
| 7 | import { InputText } from "primereact/inputtext"; |
| 8 | // 页面跳转 |
| 9 | import { useParams } from 'next/navigation' |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 10 | // 接口传输 |
| 11 | import axios from 'axios'; |
| 12 | // 回复评论 |
| 13 | import { OverlayPanel } from 'primereact/overlaypanel'; |
| 14 | import { Sidebar } from 'primereact/sidebar'; |
| 15 | // 分页 |
| 16 | import { Paginator, type PaginatorPageChangeEvent } from 'primereact/paginator'; |
| 17 | // 消息提醒 |
| 18 | import { Toast } from 'primereact/toast'; |
| 19 | // 样式 |
| 20 | import './thread.scss'; |
| 21 | |
| 22 | |
| 23 | // 评论信息 |
| 24 | interface Comment { |
| 25 | commentId: number; |
| 26 | userId: number | null; |
| 27 | replyId: number; |
| 28 | content: string; |
| 29 | createdAt: string; |
| 30 | } |
| 31 | // 评论列表 |
| 32 | interface CommentList { |
| 33 | records: Comment[]; // 当前页评论数组 |
| 34 | } |
| 35 | // 帖子信息 |
| 36 | interface ThreadInfo { |
| 37 | threadId: number; |
| 38 | userId: number; |
| 39 | threadPicture: string; |
| 40 | title: string; |
| 41 | content: string; |
| 42 | likes: number; |
| 43 | isLike: boolean; |
| 44 | createdAt: string; |
| 45 | commentNumber: number; |
| 46 | communityId: number; |
| 47 | } |
| 48 | // 用户信息 |
| 49 | interface UserInfo { |
| 50 | userId: number; |
| 51 | username: string; |
| 52 | avatar: string; |
| 53 | signature: string; |
| 54 | } |
| 55 | // 新评论接口 |
| 56 | interface NewComment { |
| 57 | userId: number; |
| 58 | threadId: number; |
| 59 | resourceId: number; |
| 60 | replyId: number; |
| 61 | content: string; |
| 62 | createdAt: string; |
| 63 | } |
| 64 | |
| 65 | |
| 66 | //帖子详情界面 |
| 67 | export default function ThreadDetailPage() { |
| 68 | // 获取URL参数,页面跳转 |
| 69 | const params = useParams<{ threadId: string }>() |
| 70 | const threadId = decodeURIComponent(params.threadId); // 防止中文路径乱码 |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 71 | // 消息提醒 |
| 72 | const toast = useRef<Toast>(null); |
| 73 | // 帖子信息 |
| 74 | const [threadInfo, setThreadInfo] = useState<ThreadInfo | null>(null); |
| 75 | // 发帖人信息 |
| 76 | const [userInfo, setUserInfo] = useState<UserInfo | null>(null); |
| 77 | // 评论人信息 |
| 78 | const [commentUserInfos, setCommentUserInfos] = useState<Map<number, UserInfo>>(new Map()); |
| 79 | //评论 |
| 80 | const [comments, setComments] = useState<Comment[]>([]); |
| 81 | const [commentValue, setCommentValue] = useState<string>(''); |
| 82 | const [totalComments, setTotalComments] = useState<number>(0); |
| 83 | // 回复 |
| 84 | const [replyValue, setReplyValue] = useState<string>(''); |
| 85 | const [visibleReply, setVisibleReply] = useState<boolean>(false);// 回复评论可视 |
| 86 | // 评论选择框 |
| 87 | const ops = useRef<OverlayPanel[]>([]); |
| 88 | |
| 89 | // 分页 |
| 90 | const [first, setFirst] = useState<number>(0); |
| 91 | const [rows, setRows] = useState<number>(5); |
| 92 | const onPageChange = (event: PaginatorPageChangeEvent) => { |
| 93 | setFirst(event.first); |
| 94 | setRows(event.rows); |
| 95 | }; |
| 96 | |
| 97 | // 获取帖子信息 |
| 98 | useEffect(() => { |
| 99 | fetchThreadInfo(); |
| 100 | }, [threadId, threadInfo]); |
| 101 | |
| 102 | const fetchThreadInfo = async () => { |
| 103 | try { |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 104 | const { data } = await axios.get(process.env.PUBLIC_URL + `/thread?threadId=${threadId}`); |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 105 | setThreadInfo(data); |
| 106 | setTotalComments(data.commentNumber); |
| 107 | } catch (err) { |
| 108 | console.error(err); |
| 109 | toast.current?.show({ severity: 'error', summary: 'error', detail: '获取帖子信息失败' }); |
| 110 | } |
| 111 | }; |
| 112 | |
| 113 | // 获取发帖人 |
| 114 | useEffect(() => { |
| 115 | if (!threadInfo) return; |
| 116 | // 发帖人 |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 117 | axios.get(process.env.PUBLIC_URL + `/user/info?userId=${threadInfo.userId}`) |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 118 | .then(res => setUserInfo(res.data)) |
| 119 | .catch(console.error); |
| 120 | }, [threadInfo]); |
| 121 | |
| 122 | // 处理点赞 |
| 123 | const handleLike = async () => { |
| 124 | if (!threadInfo) return; |
| 125 | if (!threadInfo.isLike) { |
| 126 | try { |
| 127 | const response = await axios.post( |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 128 | process.env.PUBLIC_URL + `/thread/like`, { |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 129 | params: { threadId, userId: 22301145 } |
| 130 | } |
| 131 | ); |
| 132 | fetchThreadInfo(); // 刷新帖子信息 |
| 133 | if (response.status === 200) { |
| 134 | console.log('点赞成功:', response.data); |
| 135 | toast.current?.show({ severity: 'success', summary: 'Success', detail: '点赞成功' }); |
| 136 | } |
| 137 | } catch (error) { |
| 138 | console.error('点赞失败:', error); |
| 139 | toast.current?.show({ severity: 'error', summary: 'error', detail: '点赞失败' }); |
| 140 | } |
| 141 | } else { |
| 142 | try { |
| 143 | const response = await axios.delete( |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 144 | process.env.PUBLIC_URL + `/thread/like`, { |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 145 | params: { threadId, userId: 22301145 } |
| 146 | } |
| 147 | ); |
| 148 | fetchThreadInfo(); // 刷新帖子信息 |
| 149 | if (response.status === 200) { |
| 150 | console.log('取消点赞成功:', response.data); |
| 151 | toast.current?.show({ severity: 'success', summary: 'Success', detail: '取消点赞成功' }); |
| 152 | } |
| 153 | } catch (error) { |
| 154 | console.error('取消点赞失败:', error); |
| 155 | toast.current?.show({ severity: 'error', summary: 'error', detail: '取消点赞失败' }); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | }; |
| 160 | |
| 161 | |
| 162 | // 当 threadId 或分页参数变化时重新拉评论 |
| 163 | useEffect(() => { |
| 164 | if (!threadId) return; |
| 165 | |
| 166 | fetchComments(); |
| 167 | }, [threadId, first, rows]); |
| 168 | |
| 169 | |
| 170 | //通过评论ID获取评论人信息 |
| 171 | const getReplyUserName = (replyId: number) => { |
| 172 | if (replyId == null || replyId == 0) return ''; |
| 173 | const replyComment = comments.find(comment => comment.commentId === replyId); |
| 174 | if (!replyComment?.userId) return '匿名用户'; |
| 175 | return "回复 " + commentUserInfos.get(replyComment.userId)?.username || '匿名用户'; |
| 176 | }; |
| 177 | |
| 178 | // 获取评论列表 |
| 179 | const fetchComments = async () => { |
| 180 | try { |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 181 | const pageNumber = first / rows + 1; |
| 182 | console.log("当前页" + pageNumber + "size" + rows); |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 183 | const response = await axios.get<CommentList>( |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 184 | process.env.PUBLIC_URL + `/comments`, { |
| 185 | params: { threadId, pageNumber, rows } |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 186 | } |
| 187 | ); |
| 188 | console.log('获取评论列表:', response.data.records); |
| 189 | setComments(response.data.records); |
| 190 | // 拉取评论对应用户信息 |
| 191 | response.data.records.forEach(comment => { |
| 192 | if (comment.userId != null && !commentUserInfos.has(comment.userId)) { |
| 193 | axios.get<UserInfo>( |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 194 | process.env.PUBLIC_URL + `/user/info`, |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 195 | { params: { userId: comment.userId } } |
| 196 | ).then(res => { |
| 197 | setCommentUserInfos(prev => new Map(prev).set(comment.userId!, res.data)); |
| 198 | }); |
| 199 | } |
| 200 | }); |
| 201 | } catch (err) { |
| 202 | console.error('获取评论失败', err); |
| 203 | toast.current?.show({ severity: 'error', summary: 'error', detail: '获取评论失败' }); |
| 204 | } |
| 205 | }; |
| 206 | |
| 207 | // 回复评论接口 |
| 208 | const publishReply = async (commentId: number) => { |
| 209 | if (!replyValue.trim() || !threadInfo) return; |
| 210 | console.log('发布评论:', commentId); |
| 211 | try { |
| 212 | const newComment: NewComment = { |
| 213 | userId: 22301145, |
| 214 | threadId: threadInfo.threadId, |
| 215 | resourceId: 0, |
| 216 | replyId: commentId, |
| 217 | content: commentValue, |
| 218 | createdAt: new Date().toISOString().slice(0, 19).replace('T', ' ') |
| 219 | }; |
| 220 | |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 221 | const response = await axios.post(process.env.PUBLIC_URL + '/comment', newComment); |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 222 | |
| 223 | if (response.status === 200) { |
| 224 | toast.current?.show({ severity: 'success', summary: 'Success', detail: '回复成功' }); |
| 225 | // 更新评论列表 |
| 226 | fetchComments(); |
| 227 | setVisibleReply(false) |
| 228 | // 清空输入框 |
| 229 | setReplyValue(''); |
| 230 | } |
| 231 | } catch (error) { |
| 232 | console.error('发布评论失败:', error); |
| 233 | toast.current?.show({ severity: 'error', summary: 'error', detail: '回复失败' }); |
| 234 | } |
| 235 | }; |
| 236 | |
| 237 | // 发布评论接口 |
| 238 | const publishComment = async () => { |
| 239 | if (!commentValue.trim() || !threadInfo) return; |
| 240 | |
| 241 | try { |
| 242 | const newComment: NewComment = { |
| 243 | userId: 22301145, |
| 244 | threadId: threadInfo.threadId, |
| 245 | resourceId: 0, |
| 246 | replyId: 0, // 直接评论,不是回复 |
| 247 | content: commentValue, |
| 248 | createdAt: new Date().toISOString().slice(0, 19).replace('T', ' ') |
| 249 | }; |
| 250 | |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 251 | const response = await axios.post(process.env.PUBLIC_URL + '/comment', newComment); |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 252 | |
| 253 | if (response.status === 200) { |
| 254 | toast.current?.show({ severity: 'success', summary: 'Success', detail: '评论成功' }); |
| 255 | // 更新评论列表 |
| 256 | fetchComments(); |
| 257 | // 清空输入框 |
| 258 | setCommentValue(''); |
| 259 | } |
| 260 | } catch (error) { |
| 261 | console.error('发布评论失败:', error); |
| 262 | toast.current?.show({ severity: 'error', summary: 'error', detail: '发布评论失败' }); |
| 263 | } |
| 264 | }; |
| 265 | |
| 266 | // 删除评论接口 |
| 267 | const deleteComment = async (commentId: number) => { |
| 268 | if (!threadInfo) return; |
| 269 | |
| 270 | try { |
| 271 | // 调用 DELETE 接口,URL 中最后一段是要删除的 commentId |
| 272 | const response = await axios.delete( |
LaoeGaoci | 388f776 | 2025-05-29 22:24:35 +0800 | [diff] [blame] | 273 | process.env.PUBLIC_URL + `/comment?commentId=${commentId}` |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 274 | ); |
| 275 | |
| 276 | if (response.status === 200) { |
| 277 | fetchComments(); |
| 278 | toast.current?.show({ severity: 'success', summary: 'Success', detail: '删除评论成功' }); |
| 279 | } else { |
| 280 | toast.current?.show({ severity: 'error', summary: 'error', detail: '删除评论失败' }); |
| 281 | console.error('删除评论失败,状态码:', response.status); |
| 282 | } |
| 283 | } catch (error) { |
| 284 | console.error('删除评论接口报错:', error); |
| 285 | } |
| 286 | }; |
| 287 | |
| 288 | const ReplyHeader = ( |
| 289 | <div className="flex align-items-center gap-1"> |
| 290 | <h3>回复评论</h3> |
| 291 | </div> |
| 292 | ); |
| 293 | if (!threadInfo || !userInfo) return <div>Loading...</div>; |
| 294 | return ( |
| 295 | <div className="thread-detail"> |
| 296 | <Toast ref={toast}></Toast> |
| 297 | {/* 帖子头部 */} |
| 298 | <div className="thread-header"> |
| 299 | <div className="user-info"> |
| 300 | <Avatar image={process.env.NEXT_PUBLIC_NGINX_URL + "users/" + userInfo.avatar} size="large" shape="circle" /> |
| 301 | <div className="user-meta"> |
| 302 | <h3>{userInfo.username}</h3> |
| 303 | <span>{userInfo.signature}</span> |
| 304 | </div> |
| 305 | </div> |
| 306 | <span className="post-time">{threadInfo.createdAt}</span> |
| 307 | </div> |
| 308 | |
| 309 | {/* 帖子内容 */} |
| 310 | <div className="thread-content"> |
| 311 | <h1>{threadInfo.title}</h1> |
| 312 | <div className="content-body"> |
| 313 | <Image |
| 314 | src={process.env.NEXT_PUBLIC_NGINX_URL + threadInfo.threadPicture} |
| 315 | alt={threadInfo.title} |
| 316 | width="800" |
| 317 | height="400" |
| 318 | className="thread-image" |
| 319 | /> |
| 320 | <p>{threadInfo.content}</p> |
| 321 | </div> |
| 322 | <div className="thread-actions"> |
| 323 | <Button |
| 324 | icon={"pi pi-face-smile"} |
| 325 | onClick={handleLike} |
| 326 | className={`like-button ${threadInfo.isLike ? 'liked' : ''}`} |
| 327 | label={threadInfo.likes.toString()} |
| 328 | /> |
| 329 | </div> |
| 330 | </div> |
| 331 | {/* 评论列表 */} |
| 332 | <div className="comments-section"> |
| 333 | <div className="comments-header"> |
| 334 | <h2>评论 ({totalComments})</h2> |
LaoeGaoci | ee7c577 | 2025-05-28 12:34:47 +0800 | [diff] [blame] | 335 | </div> |
| 336 | <div className="comments-input"> |
| 337 | <Avatar image={process.env.NEXT_PUBLIC_NGINX_URL + "users/" + userInfo.avatar} size="large" shape="circle" /> |
| 338 | <InputText value={commentValue} placeholder="发布你的评论" onChange={(e) => setCommentValue(e.target.value)} /> |
| 339 | <Button label="发布评论" onClick={publishComment} disabled={!commentValue.trim()} /> |
| 340 | </div> |
| 341 | <div className="comments-list"> |
| 342 | {comments.map((comment, index) => ( |
| 343 | <div key={comment.commentId} className="comment-item"> |
| 344 | <div className="comment-user"> |
| 345 | <Avatar |
| 346 | image={comment.userId ? process.env.NEXT_PUBLIC_NGINX_URL + "users/" + commentUserInfos.get(comment.userId)?.avatar : '/default-avatar.png'} |
| 347 | size="normal" |
| 348 | shape="circle" |
| 349 | /> |
| 350 | <div className="comment-meta"> |
| 351 | <span className="username"> |
| 352 | {comment.userId ? commentUserInfos.get(comment.userId)?.username : '匿名用户'} |
| 353 | </span> |
| 354 | <div className="comment-time"> |
| 355 | <span className="floor">#{index + 1}楼</span> |
| 356 | <span className="time">{comment.createdAt}</span> |
| 357 | </div> |
| 358 | </div> |
| 359 | <i className='pi pi-ellipsis-v' onClick={(e) => ops.current[index].toggle(e)} /> |
| 360 | </div> |
| 361 | <div className="comment-content"> |
| 362 | {<span className="reply-to">{getReplyUserName(comment.replyId)}</span>} |
| 363 | <p>{comment.content}</p> |
| 364 | </div> |
| 365 | <OverlayPanel // 回调 ref:把实例放到 ops.current 对应的位置 |
| 366 | ref={el => { |
| 367 | if (el) ops.current[index] = el; |
| 368 | }}> |
| 369 | <Button label="回复" text size="small" onClick={() => setVisibleReply(true)} /> |
| 370 | {comment.userId === 22301145 && |
| 371 | <Button |
| 372 | label="删除" |
| 373 | text |
| 374 | size="small" |
| 375 | onClick={() => { console.log('Deleting comment:', comment.commentId, 'by user:', comment.userId); deleteComment(comment.commentId) }} |
| 376 | /> |
| 377 | } |
| 378 | </OverlayPanel> |
| 379 | <Sidebar className='reply' header={ReplyHeader} visible={visibleReply} position="bottom" onHide={() => setVisibleReply(false)}> |
| 380 | <div className="reply-input"> |
| 381 | <Avatar image={process.env.NEXT_PUBLIC_NGINX_URL + "users/" + userInfo.avatar} size="large" shape="circle" /> |
| 382 | <InputText value={replyValue} placeholder="发布你的评论" onChange={(e) => setReplyValue(e.target.value)} /> |
| 383 | <Button label="发布评论" onClick={() => publishReply(comment.commentId)} disabled={!replyValue.trim()} /> |
| 384 | </div> |
| 385 | </Sidebar> |
| 386 | </div> |
| 387 | ))} |
| 388 | {totalComments > 5 && (<Paginator className="Paginator" first={first} rows={rows} totalRecords={totalComments} rowsPerPageOptions={[5, 10]} onPageChange={onPageChange} />)} |
| 389 | </div> |
| 390 | </div> |
| 391 | </div> |
| 392 | ); |
| 393 | } |