blob: b0ca84f6a0d125bd6376a11c436ea9660afc5f2f [file] [log] [blame]
wu32b07822025-06-24 23:10:02 +08001import React, { useState, useEffect, useCallback, useMemo } from 'react'
95630366980c1f272025-06-20 14:08:54 +08002import { useParams, useNavigate } from 'react-router-dom'
wu32b07822025-06-24 23:10:02 +08003import {
4 ArrowLeft,
5 ThumbsUp,
6 MessageCircle,
7 Share2,
8 BookmarkPlus,
9 Heart,
10 Eye,
11} from 'lucide-react'
95630366980c1f272025-06-20 14:08:54 +080012import { searchAPI } from '../api/search_jwlll'
22301008ba662fe2025-06-20 18:10:20 +080013import { getUserInfo } from '../utils/auth'
22301008e25b4b02025-06-20 22:15:31 +080014import FollowButton from './FollowButton'
wu32b07822025-06-24 23:10:02 +080015import postsAPI from '../api/posts_api' // ⇦ 包含 hasLikedPost、likePost、unlikePost
9563036699de8c092025-06-21 16:41:18 +080016import MediaPreview from './MediaPreview'
95630366980c1f272025-06-20 14:08:54 +080017import '../style/PostDetail.css'
223010083c35e492025-06-20 22:24:22 +080018import dayjs from 'dayjs'
95630366980c1f272025-06-20 14:08:54 +080019
20export default function PostDetail() {
21 const { id } = useParams()
22 const navigate = useNavigate()
wu32b07822025-06-24 23:10:02 +080023
24 // ──────────────── 状态 ────────────────
95630366980c1f272025-06-20 14:08:54 +080025 const [post, setPost] = useState(null)
26 const [loading, setLoading] = useState(true)
27 const [error, setError] = useState(null)
wu32b07822025-06-24 23:10:02 +080028
95630366980c1f272025-06-20 14:08:54 +080029 const [liked, setLiked] = useState(false)
30 const [bookmarked, setBookmarked] = useState(false)
31 const [likeCount, setLikeCount] = useState(0)
wu32b07822025-06-24 23:10:02 +080032
95630366980c1f272025-06-20 14:08:54 +080033 const [comments, setComments] = useState([])
34 const [newComment, setNewComment] = useState('')
35 const [showComments, setShowComments] = useState(false)
wu32b07822025-06-24 23:10:02 +080036
22301008e25b4b02025-06-20 22:15:31 +080037 const [isFollowing, setIsFollowing] = useState(false)
38 const [authorInfo, setAuthorInfo] = useState(null)
95630366980c1f272025-06-20 14:08:54 +080039
wu32b07822025-06-24 23:10:02 +080040 const [previewImg, setPreviewImg] = useState(null)
41 const [commentUserMap, setCommentUserMap] = useState({}) // user_id → username
42 const [commentUserAvatarMap, setCommentUserAvatarMap] = useState({}) // user_id → avatar
43
44 // 当前登录用户 ID(memo 化,组件整个生命周期只算一次)
45 const currentUserId = useMemo(() => {
46 const ui = getUserInfo()
47 return ui?.id || 'null' // 未登录就给个默认值 3
48 }, [])
49
50 // ──────────────── 拉取帖子详情 ────────────────
22301008ba662fe2025-06-20 18:10:20 +080051 const fetchPostDetail = useCallback(async () => {
95630366980c1f272025-06-20 14:08:54 +080052 setLoading(true)
53 setError(null)
54 try {
55 const data = await searchAPI.getPostDetail(id)
56 setPost(data)
57 setLikeCount(data.heat || 0)
wu32b07822025-06-24 23:10:02 +080058 } catch (err) {
59 console.error('获取帖子详情失败:', err)
95630366980c1f272025-06-20 14:08:54 +080060 setError('帖子不存在或已被删除')
61 } finally {
62 setLoading(false)
63 }
22301008ba662fe2025-06-20 18:10:20 +080064 }, [id])
95630366980c1f272025-06-20 14:08:54 +080065
wu32b07822025-06-24 23:10:02 +080066 // ──────────────── 拉取评论 ────────────────
22301008ba662fe2025-06-20 18:10:20 +080067 const fetchComments = useCallback(async () => {
95630366980c1f272025-06-20 14:08:54 +080068 try {
69 const data = await searchAPI.getComments(id)
70 setComments(data.comments || [])
wu32b07822025-06-24 23:10:02 +080071 } catch (err) {
72 console.error('获取评论失败:', err)
95630366980c1f272025-06-20 14:08:54 +080073 }
22301008ba662fe2025-06-20 18:10:20 +080074 }, [id])
75
wu32b07822025-06-24 23:10:02 +080076 // ──────────────── 组件挂载:帖子详情 + 评论 + 点赞状态 ────────────────
77 useEffect(() => {
78 fetchPostDetail()
79 fetchComments()
80
81 // 检查我是否点过赞
82 if (currentUserId) {
83 ;(async () => {
84 try {
85 const res = await postsAPI.hasLikedPost(id, currentUserId)
86 setLiked(!!res.liked)
87 } catch (err) {
88 console.error('检查点赞状态失败:', err)
89 }
90 })()
91 }
92 }, [fetchPostDetail, fetchComments, id, currentUserId])
93
94 // ──────────────── 检查是否关注作者 ────────────────
22301008e25b4b02025-06-20 22:15:31 +080095 useEffect(() => {
96 if (post && post.user_id) {
22301008e25b4b02025-06-20 22:15:31 +080097 const checkFollow = async () => {
98 try {
wu32b07822025-06-24 23:10:02 +080099 if (!currentUserId) return
100 const res = await postsAPI.getUserFollowing(currentUserId)
22301008e25b4b02025-06-20 22:15:31 +0800101 if (Array.isArray(res)) {
wu32b07822025-06-24 23:10:02 +0800102 setIsFollowing(res.some((u) => u.id === post.user_id))
22301008e25b4b02025-06-20 22:15:31 +0800103 } else if (Array.isArray(res.following)) {
wu32b07822025-06-24 23:10:02 +0800104 setIsFollowing(res.following.some((u) => u.id === post.user_id))
22301008e25b4b02025-06-20 22:15:31 +0800105 }
106 } catch {}
107 }
108 checkFollow()
109 }
wu32b07822025-06-24 23:10:02 +0800110 }, [post, currentUserId])
22301008e25b4b02025-06-20 22:15:31 +0800111
wu32b07822025-06-24 23:10:02 +0800112 // ──────────────── 作者信息 ────────────────
22301008e25b4b02025-06-20 22:15:31 +0800113 useEffect(() => {
114 if (post && post.user_id) {
wu32b07822025-06-24 23:10:02 +0800115 postsAPI
116 .getUser(post.user_id)
117 .then((res) => setAuthorInfo(res || {}))
118 .catch(() => setAuthorInfo({}))
22301008e25b4b02025-06-20 22:15:31 +0800119 }
120 }, [post])
121
wu32b07822025-06-24 23:10:02 +0800122 // ──────────────── 拉取评论用户昵称 / 头像 ────────────────
22301008082d2ab2025-06-20 22:45:17 +0800123 const fetchCommentUserNames = async (userIds) => {
124 const map = {}
wu32b07822025-06-24 23:10:02 +0800125 await Promise.all(
126 userIds.map(async (uid) => {
127 try {
128 const user = await postsAPI.getUser(uid)
129 map[uid] = user.username || user.nickname || `用户${uid}`
130 } catch {
131 map[uid] = `用户${uid}`
132 }
133 })
134 )
22301008082d2ab2025-06-20 22:45:17 +0800135 setCommentUserMap(map)
136 }
137
22301008082d2ab2025-06-20 22:45:17 +0800138 const fetchCommentUserAvatars = async (userIds) => {
139 const map = {}
wu32b07822025-06-24 23:10:02 +0800140 await Promise.all(
141 userIds.map(async (uid) => {
142 try {
143 const user = await postsAPI.getUser(uid)
144 map[uid] =
145 user.avatar && user.avatar.startsWith('http')
146 ? user.avatar
147 : user.avatar
148 ? `http://10.126.59.25:5715/static/${user.avatar}`
149 : `https://i.pravatar.cc/40?img=${uid}`
150 } catch {
151 map[uid] = `https://i.pravatar.cc/40?img=${uid}`
152 }
153 })
154 )
22301008082d2ab2025-06-20 22:45:17 +0800155 setCommentUserAvatarMap(map)
156 }
157
22301008ba662fe2025-06-20 18:10:20 +0800158 useEffect(() => {
22301008082d2ab2025-06-20 22:45:17 +0800159 if (comments.length > 0) {
wu32b07822025-06-24 23:10:02 +0800160 const uidSet = new Set(comments.map((c) => c.user_id).filter(Boolean))
161 const ids = [...uidSet]
162 fetchCommentUserNames(ids)
163 fetchCommentUserAvatars(ids)
22301008082d2ab2025-06-20 22:45:17 +0800164 }
165 }, [comments])
166
wu32b07822025-06-24 23:10:02 +0800167 // ──────────────── 交互 handlers ────────────────
168 const handleBack = () => navigate(-1)
169
95630366980c1f272025-06-20 14:08:54 +0800170 const handleLike = async () => {
wu32b07822025-06-24 23:10:02 +0800171 if (!currentUserId) {
172 alert('请先登录~')
173 return
174 }
175
95630366980c1f272025-06-20 14:08:54 +0800176 try {
wu32b07822025-06-24 23:10:02 +0800177 if (liked) {
178 // 取消点赞
179 await postsAPI.unlikePost(id, currentUserId)
95630366980c1f272025-06-20 14:08:54 +0800180 } else {
wu32b07822025-06-24 23:10:02 +0800181 // 点赞
182 await postsAPI.likePost(id, currentUserId)
95630366980c1f272025-06-20 14:08:54 +0800183 }
wu32b07822025-06-24 23:10:02 +0800184
185 // —— 关键:操作成功后重新拉一次帖子详情(里面会 setLikeCount) ——
186 await fetchPostDetail()
187
188 // —— 同步一下 liked 状态(可选,因为你直接用 fetchPostDetail 重置 likeCount,也可以重新查一下状态) ——
189 const { liked: has } = await postsAPI.hasLikedPost(id, currentUserId)
190 setLiked(!!has)
191
192 } catch (err) {
193 console.error('点赞操作失败:', err)
95630366980c1f272025-06-20 14:08:54 +0800194 }
195 }
196
197 const handleBookmark = () => {
198 setBookmarked(!bookmarked)
wu32b07822025-06-24 23:10:02 +0800199 // TODO: 调后端保存收藏状态
95630366980c1f272025-06-20 14:08:54 +0800200 }
201
202 const handleShare = () => {
95630366980c1f272025-06-20 14:08:54 +0800203 if (navigator.share) {
204 navigator.share({
205 title: post?.title,
206 text: post?.content,
207 url: window.location.href,
208 })
209 } else {
95630366980c1f272025-06-20 14:08:54 +0800210 navigator.clipboard.writeText(window.location.href)
211 alert('链接已复制到剪贴板')
212 }
213 }
wu32b07822025-06-24 23:10:02 +0800214
95630366980c1f272025-06-20 14:08:54 +0800215 const handleAddComment = async (e) => {
216 e.preventDefault()
217 if (!newComment.trim()) return
95630366980c1f272025-06-20 14:08:54 +0800218 try {
22301008ba662fe2025-06-20 18:10:20 +0800219 await searchAPI.addComment(id, currentUserId, newComment)
95630366980c1f272025-06-20 14:08:54 +0800220 setNewComment('')
wu32b07822025-06-24 23:10:02 +0800221 fetchComments()
222 } catch (err) {
223 console.error('添加评论失败:', err)
95630366980c1f272025-06-20 14:08:54 +0800224 alert('评论失败,请重试')
225 }
226 }
227
wu32b07822025-06-24 23:10:02 +0800228 const handleFollowChange = (followed) => setIsFollowing(followed)
22301008e25b4b02025-06-20 22:15:31 +0800229
wu32b07822025-06-24 23:10:02 +0800230 // ──────────────── 渲染逻辑 ────────────────
95630366980c1f272025-06-20 14:08:54 +0800231 if (loading) {
232 return (
233 <div className="post-detail">
234 <div className="loading-container">
235 <div className="loading-spinner"></div>
236 <p>加载中...</p>
237 </div>
238 </div>
239 )
240 }
241
242 if (error) {
243 return (
244 <div className="post-detail">
245 <div className="error-container">
246 <h2>😔 出错了</h2>
247 <p>{error}</p>
248 <button onClick={handleBack} className="back-btn">
249 <ArrowLeft size={20} />
250 返回
251 </button>
252 </div>
253 </div>
254 )
255 }
256
22301008e25b4b02025-06-20 22:15:31 +0800257 if (post === null || post === undefined) {
95630366980c1f272025-06-20 14:08:54 +0800258 return (
259 <div className="post-detail">
260 <div className="error-container">
22301008e25b4b02025-06-20 22:15:31 +0800261 <h2>😔 帖子不存在或已被删除</h2>
95630366980c1f272025-06-20 14:08:54 +0800262 <p>该帖子可能已被删除或不存在</p>
263 <button onClick={handleBack} className="back-btn">
264 <ArrowLeft size={20} />
265 返回
266 </button>
267 </div>
268 </div>
269 )
270 }
271
272 return (
273 <div className="post-detail">
274 {/* 顶部导航栏 */}
275 <header className="post-header">
276 <button onClick={handleBack} className="back-btn">
277 <ArrowLeft size={20} />
278 返回
279 </button>
280 <div className="header-actions">
281 <button onClick={handleShare} className="action-btn">
282 <Share2 size={20} />
283 </button>
wu32b07822025-06-24 23:10:02 +0800284 <button
285 onClick={handleBookmark}
95630366980c1f272025-06-20 14:08:54 +0800286 className={`action-btn ${bookmarked ? 'active' : ''}`}
287 >
288 <BookmarkPlus size={20} />
289 </button>
290 </div>
291 </header>
292
293 {/* 主要内容区 */}
294 <main className="post-content">
wu32b07822025-06-24 23:10:02 +0800295 {/* 标题 */}
95630366980c1f272025-06-20 14:08:54 +0800296 <h1 className="post-title">{post.title}</h1>
297
wu32b07822025-06-24 23:10:02 +0800298 {/* 作者 & 元数据 */}
95630366980c1f272025-06-20 14:08:54 +0800299 <div className="post-meta">
300 <div className="author-info">
301 <div className="avatar">
wu32b07822025-06-24 23:10:02 +0800302 {authorInfo?.avatar && authorInfo.avatar.startsWith('http') ? (
303 <img
304 className="avatar"
305 src={authorInfo.avatar}
306 alt={
307 authorInfo.username ||
308 authorInfo.nickname ||
309 post.author ||
310 '用户'
311 }
312 />
22301008e25b4b02025-06-20 22:15:31 +0800313 ) : (
wu32b07822025-06-24 23:10:02 +0800314 <img
315 className="avatar"
316 src={`https://i.pravatar.cc/40?img=${post.user_id}`}
317 alt={
318 authorInfo?.username ||
319 authorInfo?.nickname ||
320 post.author ||
321 '用户'
322 }
323 />
22301008e25b4b02025-06-20 22:15:31 +0800324 )}
95630366980c1f272025-06-20 14:08:54 +0800325 </div>
326 <div className="author-details">
wu32b07822025-06-24 23:10:02 +0800327 <span className="author-name">
328 {authorInfo?.username ||
329 authorInfo?.nickname ||
330 post.author ||
331 '匿名用户'}
95630366980c1f272025-06-20 14:08:54 +0800332 </span>
wu32b07822025-06-24 23:10:02 +0800333 <span className="post-date">
334 {post.created_at
335 ? dayjs(post.created_at).format('YYYY-MM-DD HH:mm:ss')
336 : '未知时间'}
337 </span>
338
22301008e25b4b02025-06-20 22:15:31 +0800339 {post.user_id && (
340 <FollowButton
341 userId={post.user_id}
342 isFollowing={isFollowing}
343 onFollowChange={handleFollowChange}
wu32b07822025-06-24 23:10:02 +0800344 style={{ marginLeft: 12 }}
22301008e25b4b02025-06-20 22:15:31 +0800345 />
346 )}
95630366980c1f272025-06-20 14:08:54 +0800347 </div>
348 </div>
349 <div className="post-stats">
350 <span className="stat-item">
351 <Eye size={16} />
352 {post.views || 0}
353 </span>
354 <span className="stat-item">
355 <Heart size={16} />
356 {likeCount}
357 </span>
358 </div>
359 </div>
360
361 {/* 标签 */}
wu32b07822025-06-24 23:10:02 +0800362 {post.tags?.length > 0 && (
95630366980c1f272025-06-20 14:08:54 +0800363 <div className="post-tags">
wu32b07822025-06-24 23:10:02 +0800364 {post.tags.map((tag, idx) => (
365 <span key={idx} className="tag">
366 {tag}
367 </span>
95630366980c1f272025-06-20 14:08:54 +0800368 ))}
369 </div>
370 )}
371
wu32b07822025-06-24 23:10:02 +0800372 {/* 媒体 */}
22301008c9805072025-06-20 22:38:02 +0800373 {Array.isArray(post.media_urls) && post.media_urls.length > 0 && (
wu32b07822025-06-24 23:10:02 +0800374 <div
375 className="post-media"
376 style={{
377 display: 'flex',
378 gap: 8,
379 marginBottom: 16,
380 flexWrap: 'wrap',
381 }}
382 >
22301008c9805072025-06-20 22:38:02 +0800383 {post.media_urls.map((url, idx) => (
9563036699de8c092025-06-21 16:41:18 +0800384 <MediaPreview
385 key={idx}
386 url={url}
wu32b07822025-06-24 23:10:02 +0800387 alt={`媒体${idx + 1}`}
9563036699de8c092025-06-21 16:41:18 +0800388 onClick={(mediaUrl) => {
wu32b07822025-06-24 23:10:02 +0800389 // 图片点击预览
390 const lower = mediaUrl.toLowerCase()
391 if (
392 !lower.includes('video') &&
393 !lower.endsWith('.mp4') &&
394 !lower.endsWith('.webm')
395 ) {
9563036699de8c092025-06-21 16:41:18 +0800396 setPreviewImg(mediaUrl)
397 }
398 }}
399 style={{ cursor: 'pointer' }}
400 maxWidth={320}
401 maxHeight={320}
402 />
22301008c9805072025-06-20 22:38:02 +0800403 ))}
404 </div>
405 )}
22301008c9805072025-06-20 22:38:02 +0800406 {previewImg && (
wu32b07822025-06-24 23:10:02 +0800407 <div
408 className="img-preview-mask"
409 style={{
410 position: 'fixed',
411 zIndex: 9999,
412 top: 0,
413 left: 0,
414 right: 0,
415 bottom: 0,
416 background: 'rgba(0,0,0,0.7)',
417 display: 'flex',
418 alignItems: 'center',
419 justifyContent: 'center',
420 }}
421 onClick={() => setPreviewImg(null)}
422 >
423 <img
424 src={previewImg}
425 alt="大图预览"
426 style={{
427 maxWidth: '90vw',
428 maxHeight: '90vh',
429 borderRadius: 12,
430 boxShadow: '0 4px 24px #0008',
431 }}
432 />
22301008c9805072025-06-20 22:38:02 +0800433 </div>
434 )}
435
wu32b07822025-06-24 23:10:02 +0800436 {/* 正文 */}
95630366980c1f272025-06-20 14:08:54 +0800437 <div className="post-body">
438 <p>{post.content}</p>
439 </div>
440
wu32b07822025-06-24 23:10:02 +0800441 {/* 分类 / 类型 */}
95630366980c1f272025-06-20 14:08:54 +0800442 {(post.category || post.type) && (
443 <div className="post-category">
444 {post.category && (
445 <>
446 <span className="category-label">分类:</span>
447 <span className="category-name">{post.category}</span>
448 </>
449 )}
450 {post.type && (
451 <>
wu32b07822025-06-24 23:10:02 +0800452 <span className="category-label" style={{ marginLeft: '1em' }}>
453 类型:
454 </span>
95630366980c1f272025-06-20 14:08:54 +0800455 <span className="category-name">{post.type}</span>
456 </>
457 )}
458 </div>
459 )}
460
461 {/* 评论区 */}
462 <div className="comments-section">
463 <div className="comments-header">
wu32b07822025-06-24 23:10:02 +0800464 <button
95630366980c1f272025-06-20 14:08:54 +0800465 onClick={() => setShowComments(!showComments)}
466 className="comments-toggle"
467 >
468 <MessageCircle size={20} />
469 评论 ({comments.length})
470 </button>
471 </div>
472
473 {showComments && (
474 <div className="comments-content">
wu32b07822025-06-24 23:10:02 +0800475 {/* 新评论 */}
95630366980c1f272025-06-20 14:08:54 +0800476 <form onSubmit={handleAddComment} className="comment-form">
477 <textarea
478 value={newComment}
479 onChange={(e) => setNewComment(e.target.value)}
480 placeholder="写下你的评论..."
481 className="comment-input"
482 rows={3}
483 />
484 <button type="submit" className="comment-submit">
485 发布评论
486 </button>
487 </form>
488
489 {/* 评论列表 */}
490 <div className="comments-list">
491 {comments.length === 0 ? (
492 <p className="no-comments">暂无评论</p>
493 ) : (
wu32b07822025-06-24 23:10:02 +0800494 comments.map((comment, idx) => (
495 <div key={idx} className="comment-item">
95630366980c1f272025-06-20 14:08:54 +0800496 <div className="comment-author">
497 <div className="comment-avatar">
wu32b07822025-06-24 23:10:02 +0800498 <img
499 className="avatar"
500 src={
501 commentUserAvatarMap[comment.user_id] ||
502 `https://i.pravatar.cc/40?img=${comment.user_id}`
503 }
504 alt={
505 commentUserMap[comment.user_id] ||
506 comment.user_name ||
507 '用户'
508 }
509 />
95630366980c1f272025-06-20 14:08:54 +0800510 </div>
wu32b07822025-06-24 23:10:02 +0800511 <span className="comment-name">
512 {commentUserMap[comment.user_id] ||
513 comment.user_name ||
514 '匿名用户'}
515 </span>
95630366980c1f272025-06-20 14:08:54 +0800516 <span className="comment-time">
wu32b07822025-06-24 23:10:02 +0800517 {comment.create_time
518 ? new Date(comment.create_time).toLocaleString(
519 'zh-CN'
520 )
521 : ''}
95630366980c1f272025-06-20 14:08:54 +0800522 </span>
523 </div>
wu32b07822025-06-24 23:10:02 +0800524 <div className="comment-content">{comment.content}</div>
95630366980c1f272025-06-20 14:08:54 +0800525 </div>
526 ))
527 )}
528 </div>
529 </div>
530 )}
531 </div>
532 </main>
533
534 {/* 底部操作栏 */}
535 <footer className="post-footer">
536 <div className="action-bar">
wu32b07822025-06-24 23:10:02 +0800537 <button
538 onClick={handleLike}
95630366980c1f272025-06-20 14:08:54 +0800539 className={`action-button ${liked ? 'liked' : ''}`}
540 >
541 <ThumbsUp size={20} />
542 <span>{likeCount}</span>
543 </button>
wu32b07822025-06-24 23:10:02 +0800544
545 <button
95630366980c1f272025-06-20 14:08:54 +0800546 onClick={() => setShowComments(!showComments)}
547 className="action-button"
548 >
549 <MessageCircle size={20} />
550 <span>评论</span>
551 </button>
wu32b07822025-06-24 23:10:02 +0800552
95630366980c1f272025-06-20 14:08:54 +0800553 <button onClick={handleShare} className="action-button">
554 <Share2 size={20} />
555 <span>分享</span>
556 </button>
wu32b07822025-06-24 23:10:02 +0800557
558 <button
559 onClick={handleBookmark}
95630366980c1f272025-06-20 14:08:54 +0800560 className={`action-button ${bookmarked ? 'bookmarked' : ''}`}
561 >
562 <BookmarkPlus size={20} />
563 <span>收藏</span>
564 </button>
565 </div>
566 </footer>
567 </div>
568 )
569}