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