blob: 47f905faec95f8461d5f4c681e1a46765a8aeabe [file] [log] [blame]
22301008e4acb922025-06-24 23:50:35 +08001import React, { useState, useEffect, useCallback } from 'react'
95630366980c1f272025-06-20 14:08:54 +08002import { useParams, useNavigate } from 'react-router-dom'
22301008e4acb922025-06-24 23:50:35 +08003import { ArrowLeft, ThumbsUp, MessageCircle, Share2, BookmarkPlus, Heart, Eye } from 'lucide-react'
95630366980c1f272025-06-20 14:08:54 +08004import { searchAPI } from '../api/search_jwlll'
22301008ba662fe2025-06-20 18:10:20 +08005import { getUserInfo } from '../utils/auth'
22301008e25b4b02025-06-20 22:15:31 +08006import FollowButton from './FollowButton'
22301008e4acb922025-06-24 23:50:35 +08007import postsAPI from '../api/posts_api'
9563036699de8c092025-06-21 16:41:18 +08008import MediaPreview from './MediaPreview'
95630366980c1f272025-06-20 14:08:54 +08009import '../style/PostDetail.css'
223010083c35e492025-06-20 22:24:22 +080010import dayjs from 'dayjs'
95630366980c1f272025-06-20 14:08:54 +080011
12export default function PostDetail() {
13 const { id } = useParams()
14 const navigate = useNavigate()
15 const [post, setPost] = useState(null)
16 const [loading, setLoading] = useState(true)
17 const [error, setError] = useState(null)
18 const [liked, setLiked] = useState(false)
19 const [bookmarked, setBookmarked] = useState(false)
20 const [likeCount, setLikeCount] = useState(0)
21 const [comments, setComments] = useState([])
22 const [newComment, setNewComment] = useState('')
23 const [showComments, setShowComments] = useState(false)
22301008e25b4b02025-06-20 22:15:31 +080024 const [isFollowing, setIsFollowing] = useState(false)
25 const [authorInfo, setAuthorInfo] = useState(null)
wu32b07822025-06-24 23:10:02 +080026 const [previewImg, setPreviewImg] = useState(null)
22301008e4acb922025-06-24 23:50:35 +080027 const [commentUserMap, setCommentUserMap] = useState({}) // user_id: username
28 const [commentUserAvatarMap, setCommentUserAvatarMap] = useState({}) // user_id: avatar // 获取当前用户ID
29 const getCurrentUserId = () => {
30 const userInfo = getUserInfo()
31 return userInfo?.id || '3' // 如果未登录或无用户信息,使用默认值3
32 }
22301008ba662fe2025-06-20 18:10:20 +080033 const fetchPostDetail = useCallback(async () => {
95630366980c1f272025-06-20 14:08:54 +080034 setLoading(true)
35 setError(null)
36 try {
37 const data = await searchAPI.getPostDetail(id)
38 setPost(data)
39 setLikeCount(data.heat || 0)
22301008e4acb922025-06-24 23:50:35 +080040
41 // 检查当前用户是否已点赞
42 const currentUserId = getCurrentUserId()
43 try {
44 const hasLiked = await searchAPI.hasLiked(id, currentUserId)
45 setLiked(hasLiked)
46 } catch (error) {
47 console.error('检查点赞状态失败:', error)
48 setLiked(false) // 如果检查失败,默认为未点赞
49 }
50 } catch (error) {
51 console.error('获取帖子详情失败:', error)
95630366980c1f272025-06-20 14:08:54 +080052 setError('帖子不存在或已被删除')
53 } finally {
54 setLoading(false)
55 }
22301008ba662fe2025-06-20 18:10:20 +080056 }, [id])
95630366980c1f272025-06-20 14:08:54 +080057
22301008ba662fe2025-06-20 18:10:20 +080058 const fetchComments = useCallback(async () => {
95630366980c1f272025-06-20 14:08:54 +080059 try {
60 const data = await searchAPI.getComments(id)
61 setComments(data.comments || [])
22301008e4acb922025-06-24 23:50:35 +080062 } catch (error) {
63 console.error('获取评论失败:', error)
95630366980c1f272025-06-20 14:08:54 +080064 }
22301008ba662fe2025-06-20 18:10:20 +080065 }, [id])
66
22301008e4acb922025-06-24 23:50:35 +080067 // 检查当前用户是否已关注发帖人
22301008e25b4b02025-06-20 22:15:31 +080068 useEffect(() => {
69 if (post && post.user_id) {
22301008e4acb922025-06-24 23:50:35 +080070 // 这里假设有API postsAPI.getUserFollowing
22301008e25b4b02025-06-20 22:15:31 +080071 const checkFollow = async () => {
72 try {
22301008e4acb922025-06-24 23:50:35 +080073 const userInfo = getUserInfo()
74 if (!userInfo?.id) return
75 const res = await postsAPI.getUserFollowing(userInfo.id)
22301008e25b4b02025-06-20 22:15:31 +080076 if (Array.isArray(res)) {
22301008e4acb922025-06-24 23:50:35 +080077 setIsFollowing(res.some(u => u.id === post.user_id))
22301008e25b4b02025-06-20 22:15:31 +080078 } else if (Array.isArray(res.following)) {
22301008e4acb922025-06-24 23:50:35 +080079 setIsFollowing(res.following.some(u => u.id === post.user_id))
22301008e25b4b02025-06-20 22:15:31 +080080 }
81 } catch {}
82 }
83 checkFollow()
84 }
22301008e4acb922025-06-24 23:50:35 +080085 }, [post])
22301008e25b4b02025-06-20 22:15:31 +080086
22301008e4acb922025-06-24 23:50:35 +080087 // 拉取发帖人信息
22301008e25b4b02025-06-20 22:15:31 +080088 useEffect(() => {
89 if (post && post.user_id) {
22301008e4acb922025-06-24 23:50:35 +080090 postsAPI.getUser(post.user_id).then(res => setAuthorInfo(res || {})).catch(() => setAuthorInfo({}))
22301008e25b4b02025-06-20 22:15:31 +080091 }
92 }, [post])
93
22301008e4acb922025-06-24 23:50:35 +080094 // 拉取所有评论用户昵称
22301008082d2ab2025-06-20 22:45:17 +080095 const fetchCommentUserNames = async (userIds) => {
96 const map = {}
22301008e4acb922025-06-24 23:50:35 +080097 await Promise.all(userIds.map(async uid => {
98 try {
99 const user = await postsAPI.getUser(uid)
100 map[uid] = user.username || user.nickname || `用户${uid}`
101 } catch {
102 map[uid] = `用户${uid}`
103 }
104 }))
22301008082d2ab2025-06-20 22:45:17 +0800105 setCommentUserMap(map)
106 }
107
22301008e4acb922025-06-24 23:50:35 +0800108 // 拉取所有评论用户头像
22301008082d2ab2025-06-20 22:45:17 +0800109 const fetchCommentUserAvatars = async (userIds) => {
110 const map = {}
22301008e4acb922025-06-24 23:50:35 +0800111 await Promise.all(userIds.map(async uid => {
112 try {
113 const user = await postsAPI.getUser(uid)
114 map[uid] = user.avatar && user.avatar.startsWith('http') ? user.avatar : (user.avatar ? `http://10.126.59.25:5715/static/${user.avatar}` : `https://i.pravatar.cc/40?img=${uid}`)
115 } catch {
116 map[uid] = `https://i.pravatar.cc/40?img=${uid}`
117 }
118 }))
22301008082d2ab2025-06-20 22:45:17 +0800119 setCommentUserAvatarMap(map)
120 }
121
22301008ba662fe2025-06-20 18:10:20 +0800122 useEffect(() => {
22301008e4acb922025-06-24 23:50:35 +0800123 fetchPostDetail()
124 fetchComments()
125 }, [fetchPostDetail, fetchComments])
126
127 // 评论区用户昵称和头像拉取
128 useEffect(() => {
22301008082d2ab2025-06-20 22:45:17 +0800129 if (comments.length > 0) {
22301008e4acb922025-06-24 23:50:35 +0800130 const userIds = [...new Set(comments.map(c => c.user_id).filter(Boolean))]
131 fetchCommentUserNames(userIds)
132 fetchCommentUserAvatars(userIds)
22301008082d2ab2025-06-20 22:45:17 +0800133 }
134 }, [comments])
22301008e4acb922025-06-24 23:50:35 +0800135 const handleBack = () => {
136 navigate(-1)
137 }
95630366980c1f272025-06-20 14:08:54 +0800138 const handleLike = async () => {
22301008e4acb922025-06-24 23:50:35 +0800139 const currentUserId = getCurrentUserId()
140 const originalLiked = liked
141 const originalLikeCount = likeCount
142
95630366980c1f272025-06-20 14:08:54 +0800143 try {
22301008e4acb922025-06-24 23:50:35 +0800144 // 先乐观更新UI,提供即时反馈
145 const newLiked = !liked
146 setLiked(newLiked)
147 setLikeCount(prev => newLiked ? prev + 1 : prev - 1)
148
149 // 调用后端API
150 if (newLiked) {
151 await searchAPI.likePost(id, currentUserId)
95630366980c1f272025-06-20 14:08:54 +0800152 } else {
22301008e4acb922025-06-24 23:50:35 +0800153 await searchAPI.unlikePost(id, currentUserId)
95630366980c1f272025-06-20 14:08:54 +0800154 }
22301008e4acb922025-06-24 23:50:35 +0800155
156 // API调用成功,状态已经更新,无需再次设置
157 } catch (error) {
158 console.error('点赞操作失败:', error)
159
160 // 检查是否是可以忽略的错误
161 const errorMessage = error.message || error.toString()
162 const isIgnorableError = errorMessage.includes('already liked') ||
163 errorMessage.includes('not liked yet') ||
164 errorMessage.includes('already favorited') ||
165 errorMessage.includes('not favorited yet')
166
167 if (isIgnorableError) {
168 // 这些错误可以忽略,因为最终状态是正确的
169 console.log('忽略重复操作错误:', errorMessage)
170 return
171 }
172
173 // 发生真正的错误时回滚到原始状态
174 setLiked(originalLiked)
175 setLikeCount(originalLikeCount)
176 alert('操作失败,请重试')
95630366980c1f272025-06-20 14:08:54 +0800177 }
178 }
179
180 const handleBookmark = () => {
181 setBookmarked(!bookmarked)
22301008e4acb922025-06-24 23:50:35 +0800182 // 实际项目中这里应该调用后端API保存收藏状态
95630366980c1f272025-06-20 14:08:54 +0800183 }
184
185 const handleShare = () => {
22301008e4acb922025-06-24 23:50:35 +0800186 // 分享功能
95630366980c1f272025-06-20 14:08:54 +0800187 if (navigator.share) {
188 navigator.share({
189 title: post?.title,
190 text: post?.content,
191 url: window.location.href,
192 })
193 } else {
22301008e4acb922025-06-24 23:50:35 +0800194 // 复制链接到剪贴板
95630366980c1f272025-06-20 14:08:54 +0800195 navigator.clipboard.writeText(window.location.href)
196 alert('链接已复制到剪贴板')
197 }
198 }
95630366980c1f272025-06-20 14:08:54 +0800199 const handleAddComment = async (e) => {
200 e.preventDefault()
201 if (!newComment.trim()) return
22301008e4acb922025-06-24 23:50:35 +0800202
95630366980c1f272025-06-20 14:08:54 +0800203 try {
22301008e4acb922025-06-24 23:50:35 +0800204 const currentUserId = getCurrentUserId()
22301008ba662fe2025-06-20 18:10:20 +0800205 await searchAPI.addComment(id, currentUserId, newComment)
95630366980c1f272025-06-20 14:08:54 +0800206 setNewComment('')
22301008e4acb922025-06-24 23:50:35 +0800207 fetchComments() // 刷新评论列表
208 } catch (error) {
209 console.error('添加评论失败:', error)
95630366980c1f272025-06-20 14:08:54 +0800210 alert('评论失败,请重试')
211 }
212 }
213
22301008e4acb922025-06-24 23:50:35 +0800214 // 关注后刷新关注状态
215 const handleFollowChange = async (followed) => {
216 setIsFollowing(followed)
217 // 关注/取关后重新拉取一次关注状态,保证和数据库同步
218 if (post && post.user_id) {
219 try {
220 const userInfo = getUserInfo()
221 if (!userInfo?.id) return
222 const res = await postsAPI.getUserFollowing(userInfo.id)
223 if (Array.isArray(res)) {
224 setIsFollowing(res.some(u => u.id === post.user_id))
225 } else if (Array.isArray(res.following)) {
226 setIsFollowing(res.following.some(u => u.id === post.user_id))
227 }
228 } catch {}
229 }
230 }
22301008e25b4b02025-06-20 22:15:31 +0800231
95630366980c1f272025-06-20 14:08:54 +0800232 if (loading) {
233 return (
234 <div className="post-detail">
235 <div className="loading-container">
236 <div className="loading-spinner"></div>
237 <p>加载中...</p>
238 </div>
239 </div>
240 )
241 }
242
22301008e4acb922025-06-24 23:50:35 +0800243 // 优化错误和不存在的判断逻辑
95630366980c1f272025-06-20 14:08:54 +0800244 if (error) {
245 return (
246 <div className="post-detail">
247 <div className="error-container">
248 <h2>😔 出错了</h2>
249 <p>{error}</p>
250 <button onClick={handleBack} className="back-btn">
251 <ArrowLeft size={20} />
252 返回
253 </button>
254 </div>
255 </div>
256 )
257 }
258
22301008e4acb922025-06-24 23:50:35 +0800259 // 只有明确为 null 或 undefined 时才显示不存在
22301008e25b4b02025-06-20 22:15:31 +0800260 if (post === null || post === undefined) {
95630366980c1f272025-06-20 14:08:54 +0800261 return (
262 <div className="post-detail">
263 <div className="error-container">
22301008e25b4b02025-06-20 22:15:31 +0800264 <h2>😔 帖子不存在或已被删除</h2>
95630366980c1f272025-06-20 14:08:54 +0800265 <p>该帖子可能已被删除或不存在</p>
266 <button onClick={handleBack} className="back-btn">
267 <ArrowLeft size={20} />
268 返回
269 </button>
270 </div>
271 </div>
272 )
273 }
274
275 return (
276 <div className="post-detail">
277 {/* 顶部导航栏 */}
278 <header className="post-header">
279 <button onClick={handleBack} className="back-btn">
280 <ArrowLeft size={20} />
281 返回
282 </button>
283 <div className="header-actions">
284 <button onClick={handleShare} className="action-btn">
285 <Share2 size={20} />
286 </button>
22301008e4acb922025-06-24 23:50:35 +0800287 <button
288 onClick={handleBookmark}
95630366980c1f272025-06-20 14:08:54 +0800289 className={`action-btn ${bookmarked ? 'active' : ''}`}
290 >
291 <BookmarkPlus size={20} />
292 </button>
293 </div>
294 </header>
295
296 {/* 主要内容区 */}
297 <main className="post-content">
22301008e4acb922025-06-24 23:50:35 +0800298 {/* 帖子标题 */}
95630366980c1f272025-06-20 14:08:54 +0800299 <h1 className="post-title">{post.title}</h1>
300
22301008e4acb922025-06-24 23:50:35 +0800301 {/* 作者信息和元数据 */}
95630366980c1f272025-06-20 14:08:54 +0800302 <div className="post-meta">
303 <div className="author-info">
304 <div className="avatar">
22301008e4acb922025-06-24 23:50:35 +0800305 {authorInfo && authorInfo.avatar && authorInfo.avatar.startsWith('http') ? (
306 <img className="avatar" src={authorInfo.avatar} alt={authorInfo.username || authorInfo.nickname || post.author || '用户'} />
22301008e25b4b02025-06-20 22:15:31 +0800307 ) : (
22301008e4acb922025-06-24 23:50:35 +0800308 <img className="avatar" src={`https://i.pravatar.cc/40?img=${post.user_id}`} alt={authorInfo?.username || authorInfo?.nickname || post.author || '用户'} />
22301008e25b4b02025-06-20 22:15:31 +0800309 )}
95630366980c1f272025-06-20 14:08:54 +0800310 </div>
311 <div className="author-details">
22301008e4acb922025-06-24 23:50:35 +0800312 <span className="author-name">{authorInfo?.username || authorInfo?.nickname || post.author || '匿名用户'}</span>
wu32b07822025-06-24 23:10:02 +0800313 <span className="post-date">
22301008e4acb922025-06-24 23:50:35 +0800314 {post.created_at ? dayjs(post.created_at).format('YYYY-MM-DD HH:mm:ss') : '未知时间'}
wu32b07822025-06-24 23:10:02 +0800315 </span>
22301008e4acb922025-06-24 23:50:35 +0800316 {/* 关注按钮 */}
22301008e25b4b02025-06-20 22:15:31 +0800317 {post.user_id && (
318 <FollowButton
319 userId={post.user_id}
320 isFollowing={isFollowing}
321 onFollowChange={handleFollowChange}
22301008e4acb922025-06-24 23:50:35 +0800322 style={{marginLeft: 12}}
22301008e25b4b02025-06-20 22:15:31 +0800323 />
324 )}
95630366980c1f272025-06-20 14:08:54 +0800325 </div>
326 </div>
327 <div className="post-stats">
328 <span className="stat-item">
329 <Eye size={16} />
330 {post.views || 0}
331 </span>
332 <span className="stat-item">
333 <Heart size={16} />
334 {likeCount}
335 </span>
336 </div>
337 </div>
338
339 {/* 标签 */}
22301008e4acb922025-06-24 23:50:35 +0800340 {post.tags && post.tags.length > 0 && (
95630366980c1f272025-06-20 14:08:54 +0800341 <div className="post-tags">
22301008e4acb922025-06-24 23:50:35 +0800342 {post.tags.map((tag, index) => (
343 <span key={index} className="tag">{tag}</span>
95630366980c1f272025-06-20 14:08:54 +0800344 ))}
345 </div>
346 )}
347
22301008e4acb922025-06-24 23:50:35 +0800348 {/* 帖子媒体(支持多图/多视频) */}
22301008c9805072025-06-20 22:38:02 +0800349 {Array.isArray(post.media_urls) && post.media_urls.length > 0 && (
22301008e4acb922025-06-24 23:50:35 +0800350 <div className="post-media" style={{display:'flex',gap:8,marginBottom:16,flexWrap:'wrap'}}>
22301008c9805072025-06-20 22:38:02 +0800351 {post.media_urls.map((url, idx) => (
9563036699de8c092025-06-21 16:41:18 +0800352 <MediaPreview
353 key={idx}
354 url={url}
22301008e4acb922025-06-24 23:50:35 +0800355 alt={`媒体${idx+1}`}
9563036699de8c092025-06-21 16:41:18 +0800356 onClick={(mediaUrl) => {
22301008e4acb922025-06-24 23:50:35 +0800357 // 对于图片,显示预览
358 if (!mediaUrl.toLowerCase().includes('video') && !mediaUrl.includes('.mp4') && !mediaUrl.includes('.webm')) {
9563036699de8c092025-06-21 16:41:18 +0800359 setPreviewImg(mediaUrl)
360 }
361 }}
362 style={{ cursor: 'pointer' }}
363 maxWidth={320}
364 maxHeight={320}
365 />
22301008c9805072025-06-20 22:38:02 +0800366 ))}
367 </div>
368 )}
22301008e4acb922025-06-24 23:50:35 +0800369 {/* 大图预览弹窗 */}
22301008c9805072025-06-20 22:38:02 +0800370 {previewImg && (
22301008e4acb922025-06-24 23:50:35 +0800371 <div className="img-preview-mask" style={{position:'fixed',zIndex:9999,top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.7)',display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>setPreviewImg(null)}>
372 <img src={previewImg} alt="大图预览" style={{maxWidth:'90vw',maxHeight:'90vh',borderRadius:12,boxShadow:'0 4px 24px #0008'}} />
22301008c9805072025-06-20 22:38:02 +0800373 </div>
374 )}
375
22301008e4acb922025-06-24 23:50:35 +0800376 {/* 帖子正文 */}
95630366980c1f272025-06-20 14:08:54 +0800377 <div className="post-body">
378 <p>{post.content}</p>
379 </div>
380
22301008e4acb922025-06-24 23:50:35 +0800381 {/* 类别信息 */}
95630366980c1f272025-06-20 14:08:54 +0800382 {(post.category || post.type) && (
383 <div className="post-category">
384 {post.category && (
385 <>
386 <span className="category-label">分类:</span>
387 <span className="category-name">{post.category}</span>
388 </>
389 )}
390 {post.type && (
391 <>
22301008e4acb922025-06-24 23:50:35 +0800392 <span className="category-label" style={{marginLeft: '1em'}}>类型:</span>
95630366980c1f272025-06-20 14:08:54 +0800393 <span className="category-name">{post.type}</span>
394 </>
395 )}
396 </div>
397 )}
398
399 {/* 评论区 */}
400 <div className="comments-section">
401 <div className="comments-header">
22301008e4acb922025-06-24 23:50:35 +0800402 <button
95630366980c1f272025-06-20 14:08:54 +0800403 onClick={() => setShowComments(!showComments)}
404 className="comments-toggle"
405 >
406 <MessageCircle size={20} />
407 评论 ({comments.length})
408 </button>
409 </div>
410
411 {showComments && (
412 <div className="comments-content">
22301008e4acb922025-06-24 23:50:35 +0800413 {/* 添加评论 */}
95630366980c1f272025-06-20 14:08:54 +0800414 <form onSubmit={handleAddComment} className="comment-form">
415 <textarea
416 value={newComment}
417 onChange={(e) => setNewComment(e.target.value)}
418 placeholder="写下你的评论..."
419 className="comment-input"
420 rows={3}
421 />
422 <button type="submit" className="comment-submit">
423 发布评论
424 </button>
425 </form>
426
427 {/* 评论列表 */}
428 <div className="comments-list">
429 {comments.length === 0 ? (
430 <p className="no-comments">暂无评论</p>
431 ) : (
22301008e4acb922025-06-24 23:50:35 +0800432 comments.map((comment, index) => (
433 <div key={index} className="comment-item">
95630366980c1f272025-06-20 14:08:54 +0800434 <div className="comment-author">
435 <div className="comment-avatar">
22301008e4acb922025-06-24 23:50:35 +0800436 <img className="avatar" src={commentUserAvatarMap[comment.user_id] || `https://i.pravatar.cc/40?img=${comment.user_id}`} alt={commentUserMap[comment.user_id] || comment.user_name || '用户'} />
95630366980c1f272025-06-20 14:08:54 +0800437 </div>
22301008e4acb922025-06-24 23:50:35 +0800438 <span className="comment-name">{commentUserMap[comment.user_id] || comment.user_name || '匿名用户'}</span>
95630366980c1f272025-06-20 14:08:54 +0800439 <span className="comment-time">
22301008e4acb922025-06-24 23:50:35 +0800440 {comment.create_time ? new Date(comment.create_time).toLocaleString('zh-CN') : ''}
95630366980c1f272025-06-20 14:08:54 +0800441 </span>
442 </div>
22301008e4acb922025-06-24 23:50:35 +0800443 <div className="comment-content">
444 {comment.content}
445 </div>
95630366980c1f272025-06-20 14:08:54 +0800446 </div>
447 ))
448 )}
449 </div>
450 </div>
451 )}
452 </div>
453 </main>
454
455 {/* 底部操作栏 */}
456 <footer className="post-footer">
457 <div className="action-bar">
22301008e4acb922025-06-24 23:50:35 +0800458 <button
459 onClick={handleLike}
95630366980c1f272025-06-20 14:08:54 +0800460 className={`action-button ${liked ? 'liked' : ''}`}
461 >
462 <ThumbsUp size={20} />
463 <span>{likeCount}</span>
464 </button>
22301008e4acb922025-06-24 23:50:35 +0800465
466 <button
95630366980c1f272025-06-20 14:08:54 +0800467 onClick={() => setShowComments(!showComments)}
468 className="action-button"
469 >
470 <MessageCircle size={20} />
471 <span>评论</span>
472 </button>
22301008e4acb922025-06-24 23:50:35 +0800473
95630366980c1f272025-06-20 14:08:54 +0800474 <button onClick={handleShare} className="action-button">
475 <Share2 size={20} />
476 <span>分享</span>
477 </button>
22301008e4acb922025-06-24 23:50:35 +0800478
479 <button
480 onClick={handleBookmark}
95630366980c1f272025-06-20 14:08:54 +0800481 className={`action-button ${bookmarked ? 'bookmarked' : ''}`}
482 >
483 <BookmarkPlus size={20} />
484 <span>收藏</span>
485 </button>
486 </div>
487 </footer>
488 </div>
489 )
490}