/** * 帖子详情页 PostDetailScreen - QQ频道风格(响应式版本) * 胡萝卜BBS - 显示完整帖子内容和评论 * 桌面端使用双栏布局,移动端使用单栏布局 */ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, FlatList, StyleSheet, RefreshControl, TouchableOpacity, TextInput, Keyboard, Platform, Modal, KeyboardEvent, KeyboardAvoidingView, Alert, ScrollView, Image, Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; import { RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; type NavigationProp = NativeStackNavigationProp; type PostDetailRouteProp = RouteProp; export const PostDetailScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); const insets = useSafeAreaInsets(); const postId = route.params?.postId || ''; const shouldScrollToComments = route.params?.scrollToComments || false; // 使用响应式 hook const { width, isMobile, isTablet, isDesktop, isWideScreen, isLandscape } = useResponsive(); // 响应式间距 const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 }); const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 }); const currentUser = useCurrentUser(); const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); // 使用游标分页 Hook 管理评论列表 const { list: paginatedComments, isLoading: isCommentsLoading, isRefreshing: isCommentsRefreshing, hasMore: hasMoreComments, loadMore: loadMoreComments, refresh: refreshComments, error: commentsError, } = useCursorPagination( async ({ cursor, pageSize }) => { return await commentService.getPostCommentsCursor(postId, { cursor, page_size: pageSize, }); }, { pageSize: 20 } ); // 同步分页评论到本地状态 useEffect(() => { setComments(paginatedComments); }, [paginatedComments]); const [commentText, setCommentText] = useState(''); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [allImages, setAllImages] = useState([]); const [keyboardHeight, setKeyboardHeight] = useState(0); const [isDeleting, setIsDeleting] = useState(false); // 评论图片相关状态 const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]); const [isFollowing, setIsFollowing] = useState(false); const [isFollowingMe, setIsFollowingMe] = useState(false); const [isFollowLoading, setIsFollowLoading] = useState(false); const flatListRef = useRef(null); const hasRecordedView = useRef(false); // 是否已记录浏览量 // 投票相关状态 const [voteResult, setVoteResult] = useState(null); const [isVoteLoading, setIsVoteLoading] = useState(false); // 桌面端侧边栏宽度 const sidebarWidth = useResponsiveValue({ xs: 0, sm: 0, md: 280, lg: 320, xl: 360, '2xl': 400, '3xl': 440, '4xl': 480, }); // 是否使用双栏布局(桌面端且横屏) const useDualColumnLayout = useMemo(() => { return (isDesktop || isWideScreen) && width >= 1024; }, [isDesktop, isWideScreen, width]); // 加载帖子详情(可选择是否记录浏览量) const loadPostDetail = useCallback(async (recordView: boolean = false) => { if (!postId) { setLoading(false); return; } try { // 使用 ProcessPostUseCase 获取帖子详情 const postData = await processPostUseCase.fetchPostById(postId); if (postData) { // 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性 setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { setIsFollowing((postData.author as any).is_following || false); setIsFollowingMe((postData.author as any).is_following_me || false); } // 只在首次加载时记录浏览量 if (recordView && !hasRecordedView.current) { hasRecordedView.current = true; // 异步记录浏览量,不阻塞加载 postService.recordView(postId).catch(err => { console.error('记录浏览量失败:', err); }); } // 如果是投票帖子,立即加载投票数据 if ((postData as any).is_vote) { setIsVoteLoading(true); try { const voteData = await voteService.getVoteResult(postId); if (voteData) { setVoteResult(voteData); } } catch (voteErr) { console.error('加载投票数据失败:', voteErr); } finally { setIsVoteLoading(false); } } } else { // 如果API返回空,尝试从store中获取 const storePosts = useUserStore.getState().posts; const updatedPost = storePosts.find(p => p.id === postId); if (updatedPost) { setPost(updatedPost); // 初始化关注状态 if (updatedPost.author) { setIsFollowing((updatedPost.author as any).is_following || false); setIsFollowingMe((updatedPost.author as any).is_following_me || false); } } } // 加载评论(使用游标分页刷新) await refreshComments(); } catch (error) { console.error('加载帖子详情失败:', error); } finally { setLoading(false); } }, [postId]); // 首次加载时记录浏览量 useEffect(() => { loadPostDetail(true); }, [loadPostDetail]); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { loadPostDetail(false); }); return unsubscribe; }, [navigation, loadPostDetail]); // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } }, [shouldScrollToComments, loading, comments?.length]); // 动态设置导航头部 useEffect(() => { if (!post?.author) return; const author = post.author; navigation.setOptions({ headerTitle: () => ( handleUserPress(author.id)} style={styles.headerAvatarWrapper} > handleUserPress(author.id)}> {author.nickname} ), headerRight: () => ( {renderFollowButton()} ), }); }, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation]); // 监听键盘事件 useEffect(() => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); }; const keyboardWillHide = () => { setKeyboardHeight(0); }; const showSubscription = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', keyboardWillShow ); const hideSubscription = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', keyboardWillHide ); return () => { showSubscription.remove(); hideSubscription.remove(); }; }, []); // 下拉刷新 - 同时刷新帖子和评论 const onRefresh = useCallback(async () => { setRefreshing(true); await loadPostDetail(); await refreshComments(); setRefreshing(false); }, [loadPostDetail, refreshComments]); const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; const date = new Date(dateString); if (Number.isNaN(date.getTime())) return ''; const pad = (num: number) => String(num).padStart(2, '0'); return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; }; const formatRelativeTime = (dateString?: string | null): string => { if (!dateString) return ''; const date = new Date(dateString); if (Number.isNaN(date.getTime())) return ''; const now = Date.now(); const diffMs = now - date.getTime(); if (diffMs < 0) return formatDateTime(dateString); const minuteMs = 60 * 1000; const hourMs = 60 * minuteMs; const dayMs = 24 * hourMs; if (diffMs < minuteMs) return '刚刚'; if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}分钟前`; if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}小时前`; if (diffMs < 2 * dayMs) { const pad = (num: number) => String(num).padStart(2, '0'); return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`; } return formatDateTime(dateString); }; const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { if (!createdAt || !updatedAt) return false; const created = new Date(createdAt).getTime(); const updated = new Date(updatedAt).getTime(); if (Number.isNaN(created) || Number.isNaN(updated)) return false; return updated-created > 1000; }; // 格式化数字 const formatNumber = (num: number): string => { if (num >= 10000) { return (num / 10000).toFixed(1) + 'w'; } if (num >= 1000) { return (num / 1000).toFixed(1) + 'k'; } return num.toString(); }; // 点赞帖子 const handleLike = useCallback(async () => { if (!post) return; // 先保存旧状态用于回滚 const oldIsLiked = post.is_liked; const oldLikesCount = post.likes_count; // 乐观更新本地状态 setPost(prev => prev ? { ...prev, is_liked: !prev.is_liked, likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1 } : null); try { if (oldIsLiked) { await processPostUseCase.unlikePost(post.id); } else { await processPostUseCase.likePost(post.id); } // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('点赞操作失败:', error); // 失败时回滚状态 setPost(prev => prev ? { ...prev, is_liked: oldIsLiked, likes_count: oldLikesCount } : null); } }, [post]); // 收藏帖子 const handleFavorite = useCallback(async () => { if (!post) return; // 先保存旧状态用于回滚 const oldIsFavorited = post.is_favorited; const oldFavoritesCount = post.favorites_count; // 乐观更新本地状态 setPost(prev => prev ? { ...prev, is_favorited: !prev.is_favorited, favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1 } : null); try { if (oldIsFavorited) { await processPostUseCase.unfavoritePost(post.id); } else { await processPostUseCase.favoritePost(post.id); } // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('收藏操作失败:', error); // 失败时回滚状态 setPost(prev => prev ? { ...prev, is_favorited: oldIsFavorited, favorites_count: oldFavoritesCount } : null); } }, [post]); // 分享帖子 const handleShare = useCallback(async () => { if (!post?.id) return; try { await postService.sharePost(post.id); } catch (error) { console.error('上报分享次数失败:', error); } const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; Clipboard.setString(postUrl); Alert.alert('已复制', '帖子链接已复制到剪贴板'); }, [post?.id]); // 投票处理函数 const handleVote = useCallback(async (optionId: string) => { if (!post || !voteResult || isVoteLoading) return; // 保存旧状态用于回滚 const oldVoteResult = { ...voteResult }; // 乐观更新 setVoteResult(prev => { if (!prev) return null; return { ...prev, has_voted: true, voted_option_id: optionId, total_votes: prev.total_votes + 1, options: prev.options.map(opt => opt.id === optionId ? { ...opt, votes_count: opt.votes_count + 1 } : opt ), }; }); setIsVoteLoading(true); try { const success = await voteService.vote(post.id, optionId); if (!success) { // 失败时回滚 setVoteResult(oldVoteResult); } } catch (error) { console.error('投票失败:', error); // 失败时回滚 setVoteResult(oldVoteResult); } finally { setIsVoteLoading(false); } }, [post, voteResult, isVoteLoading]); // 取消投票处理函数 const handleUnvote = useCallback(async () => { if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return; const votedOptionId = voteResult.voted_option_id; // 保存旧状态用于回滚 const oldVoteResult = { ...voteResult }; // 乐观更新 setVoteResult(prev => { if (!prev) return null; return { ...prev, has_voted: false, voted_option_id: undefined, total_votes: Math.max(0, prev.total_votes - 1), options: prev.options.map(opt => opt.id === votedOptionId ? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) } : opt ), }; }); setIsVoteLoading(true); try { const success = await voteService.unvote(post.id); if (!success) { // 失败时回滚 setVoteResult(oldVoteResult); } } catch (error) { console.error('取消投票失败:', error); // 失败时回滚 setVoteResult(oldVoteResult); } finally { setIsVoteLoading(false); } }, [post, voteResult, isVoteLoading]); // 删除帖子 const handleDeletePost = useCallback(() => { if (!post || isDeleting) return; // 检查是否为帖子作者 if (currentUser?.id !== post.author?.id) { Alert.alert('无法删除', '只能删除自己的帖子'); return; } Alert.alert( '删除帖子', '确定要删除这篇帖子吗?删除后将无法恢复。', [ { text: '取消', style: 'cancel', }, { text: '删除', style: 'destructive', onPress: async () => { setIsDeleting(true); try { await processPostUseCase.deletePost(post.id); Alert.alert('删除成功', '帖子已删除', [ { text: '确定', onPress: () => navigation.goBack(), }, ]); } catch (error) { console.error('删除帖子失败:', error); Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); } finally { setIsDeleting(false); } }, }, ], ); }, [post, isDeleting, currentUser?.id, navigation]); const handleEditPost = useCallback(() => { if (!post) return; navigation.navigate('CreatePost', { mode: 'edit', postId: post.id, }); }, [navigation, post]); // 点击图片查看大图 const handleImagePress = useCallback((images: ImageGridItem[], index: number) => { setAllImages(images); setSelectedImageIndex(index); setShowImageModal(true); }, []); // 选择评论图片 const handlePickCommentImage = async () => { if (commentImages.length >= 3) { Alert.alert('提示', '评论最多只能添加3张图片'); return; } const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permissionResult.granted) { Alert.alert('权限不足', '需要访问相册权限来选择图片'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsMultipleSelection: true, selectionLimit: 3 - commentImages.length, quality: 0.8, }); if (!result.canceled && result.assets) { const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); setCommentImages(prev => [...prev, ...newImages]); // 上传图片 for (let i = 0; i < newImages.length; i++) { const asset = result.assets[i]; const uploadResult = await uploadService.uploadImage({ uri: asset.uri, type: asset.mimeType || 'image/jpeg', }); if (uploadResult) { setCommentImages(prev => { const updated = [...prev]; const index = prev.findIndex(img => img.uri === asset.uri); if (index !== -1) { updated[index] = { uri: uploadResult.url, uploading: false }; } return updated; }); } else { Alert.alert('上传失败', '图片上传失败,请重试'); setCommentImages(prev => prev.filter(img => img.uri !== asset.uri)); } } } }; // 删除评论图片 const handleRemoveCommentImage = (index: number) => { setCommentImages(prev => prev.filter((_, i) => i !== index)); }; // 发送评论 const handleSendComment = async () => { // 检查是否有内容(文字或图片) const hasContent = commentText.trim() || commentImages.length > 0; if (!hasContent || !post) return; // 检查是否有图片正在上传 const uploadingImages = commentImages.filter(img => img.uploading); if (uploadingImages.length > 0) { Alert.alert('请稍候', '图片正在上传中,请稍后再试'); return; } const tempId = `new-${Date.now()}`; const userId = currentUser?.id || ''; const isReply = !!replyingTo; const parentId = isReply ? replyingTo.id : null; // 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论) const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null; // 获取已上传图片的URL列表 const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri); // 先创建临时评论显示在列表中 const tempComment: Comment = { id: tempId, post_id: post.id, user_id: userId, parent_id: parentId, root_id: rootId, target_id: parentId, content: commentText.trim(), images: uploadedImageUrls.map(url => ({ url })), likes_count: 0, replies_count: 0, is_liked: false, author: currentUser || { id: '', username: 'guest', nickname: '游客', avatar: '', cover_url: '', bio: '', website: '', location: '', phone: undefined, email: undefined, posts_count: 0, followers_count: 0, following_count: 0, created_at: new Date().toISOString(), }, created_at: new Date().toISOString(), }; // 如果是回复,添加到根评论的 replies 中;否则添加到顶级评论列表 if (isReply && rootId) { // 回复评论:更新根评论的 replies 和 replies_count setComments(prev => prev.map(c => { if (c.id === rootId) { return { ...c, replies: [...(c.replies || []), tempComment], replies_count: (c.replies_count || 0) + 1 }; } return c; })); } else { // 顶级评论:添加到评论列表开头 setComments(prev => [tempComment, ...prev]); } setCommentText(''); setCommentImages([]); // 清除图片 setReplyingTo(null); // 清除回复状态 // 更新帖子评论数 setPost(prev => prev ? { ...prev, comments_count: prev.comments_count + 1 } : null); // 调用API创建评论 try { const createdComment = await commentService.createComment({ postId: post.id, content: commentText.trim(), parentId: parentId || undefined, images: uploadedImageUrls, }); if (createdComment) { // 用服务器返回的真实评论替换临时评论 if (isReply && rootId) { // 回复评论:更新根评论的 replies setComments(prev => prev.map(c => { if (c.id === rootId) { return { ...c, replies: (c.replies || []).map(r => r.id === tempId ? createdComment : r), }; } return c; })); } else { // 顶级评论:替换列表中的临时评论 setComments(prev => prev.map(c => c.id === tempId ? createdComment : c) ); } showPrompt({ type: 'info', title: '审核中', message: isReply ? '回复已提交,内容审核中,稍后展示' : '评论已提交,内容审核中,稍后展示', duration: 2400, }); } else { // 创建失败,移除临时评论 if (isReply && rootId) { // 回复评论:从根评论的 replies 中移除 setComments(prev => prev.map(c => { if (c.id === rootId) { return { ...c, replies: (c.replies || []).filter(r => r.id !== tempId), replies_count: Math.max(0, (c.replies_count || 0) - 1) }; } return c; })); } else { // 顶级评论:从列表中移除 setComments(prev => prev.filter(c => c.id !== tempId)); } setPost(prev => prev ? { ...prev, comments_count: Math.max(0, prev.comments_count - 1) } : null); } } catch (error) { console.error('创建评论失败:', error); // 创建失败,移除临时评论 if (isReply && rootId) { // 回复评论:从根评论的 replies 中移除 setComments(prev => prev.map(c => { if (c.id === rootId) { return { ...c, replies: (c.replies || []).filter(r => r.id !== tempId), replies_count: Math.max(0, (c.replies_count || 0) - 1) }; } return c; })); } else { // 顶级评论:从列表中移除 setComments(prev => prev.filter(c => c.id !== tempId)); } setPost(prev => prev ? { ...prev, comments_count: Math.max(0, prev.comments_count - 1) } : null); } }; // 点赞评论(包括回复) const handleLikeComment = async (comment: Comment) => { // 先保存旧状态用于回滚 const oldIsLiked = comment.is_liked; const oldLikesCount = comment.likes_count; // 乐观更新本地状态 - 辅助函数用于更新评论或回复 const updateCommentLike = (c: Comment): Comment => { // 如果是目标评论/回复 if (c.id === comment.id) { return { ...c, is_liked: !c.is_liked, likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1 }; } // 如果有回复,递归查找 if (c.replies && c.replies.length > 0) { return { ...c, replies: c.replies.map(r => updateCommentLike(r)) }; } return c; }; // 更新本地状态 setComments(prev => prev.map(c => updateCommentLike(c))); try { if (oldIsLiked) { await commentService.unlikeComment(comment.id); } else { await commentService.likeComment(comment.id); } } catch (error) { console.error('评论点赞操作失败:', error); // 失败时回滚状态 const rollbackCommentLike = (c: Comment): Comment => { if (c.id === comment.id) { return { ...c, is_liked: oldIsLiked, likes_count: oldLikesCount }; } if (c.replies && c.replies.length > 0) { return { ...c, replies: c.replies.map(r => rollbackCommentLike(r)) }; } return c; }; setComments(prev => prev.map(c => rollbackCommentLike(c))); } }; // 删除评论 const handleDeleteComment = async (comment: Comment) => { if (!post) return; // 检查是否为评论作者 if (currentUser?.id !== comment.author?.id) { Alert.alert('无法删除', '只能删除自己的评论'); return; } try { const success = await commentService.deleteComment(comment.id); if (success) { // 判断是顶级评论还是回复 if (comment.root_id) { // 这是一个回复,从根评论的 replies 中移除 setComments(prev => prev.map(c => { if (c.id === comment.root_id) { return { ...c, replies: (c.replies || []).filter(r => r.id !== comment.id), replies_count: Math.max(0, (c.replies_count || 0) - 1) }; } return c; })); } else { // 这是一个顶级评论,从列表中移除 setComments(prev => prev.filter(c => c.id !== comment.id)); } // 更新帖子评论数 setPost(prev => prev ? { ...prev, comments_count: Math.max(0, prev.comments_count - 1) } : null); Alert.alert('删除成功', '评论已删除'); } else { Alert.alert('删除失败', '删除评论时发生错误,请稍后重试'); } } catch (error) { console.error('删除评论失败:', error); Alert.alert('删除失败', '删除评论时发生错误,请稍后重试'); } }; // 跳转到用户主页 const handleUserPress = (userId: string) => { navigation.navigate('UserProfile', { userId }); }; // 渲染身份标识 const renderBadges = () => { const badges = []; if (post?.author?.id === post?.user_id) { badges.push( 楼主 ); } if (post?.author?.id === '1') { // 管理员 badges.push( 管理员 ); } return badges; }; // 获取关注按钮配置(类似B站/小红书的互关逻辑) const getFollowButtonConfig = (): { title: string; style: 'primary' | 'outline' | 'mutual' } => { if (isFollowing && isFollowingMe) { // 已互关 return { title: '互相关注', style: 'mutual' }; } else if (isFollowing) { // 已关注但对方未回关 return { title: '已关注', style: 'outline' }; } else if (isFollowingMe) { // 对方关注了我,但我没关注对方 - 显示回关 return { title: '回关', style: 'primary' }; } else { // 互不关注 return { title: '+ 关注', style: 'primary' }; } }; // 处理关注/取消关注 const handleFollow = async () => { if (!post?.author?.id || isFollowLoading) return; // 不能关注自己 if (currentUser?.id === post.author.id) return; setIsFollowLoading(true); try { if (isFollowing) { // 取消关注 const success = await authService.unfollowUser(post.author.id); if (success) { setIsFollowing(false); } } else { // 关注 const success = await authService.followUser(post.author.id); if (success) { setIsFollowing(true); } } } catch (error) { console.error('关注操作失败:', error); } finally { setIsFollowLoading(false); } }; // 渲染关注按钮 const renderFollowButton = () => { if (!post?.author?.id || currentUser?.id === post.author.id) return null; const config = getFollowButtonConfig(); return ( {config.title} ); }; // 使用 useMemo 缓存图片数据,避免输入时重新创建数组导致图片重新加载 const postImages = useMemo(() => { if (!post?.images) return []; return post.images.map(img => ({ id: img.id, url: img.url, thumbnail_url: img.thumbnail_url, width: img.width, height: img.height, })); }, [post?.images]); // 渲染帖子头部 - 小红书/微博风格 const renderPostHeader = useCallback(() => { if (!post) return null; // 响应式图片间距和圆角 const imageGap = isDesktop ? 12 : isTablet ? 10 : 8; const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.lg; return ( {/* 标题 */} {post.title && ( {post.title} )} {/* 内容 - 增大字体 */} {post.content} {/* 图片 - 详情页显示所有图片,不限制数量 */} {postImages.length > 0 && ( )} {/* 投票卡片 */} {post.is_vote && voteResult && ( )} {/* 发帖时间和浏览量 - 放在图片下方 */} 发布 {formatRelativeTime(post.created_at)} {isPostEdited(post.created_at, post.updated_at) && ( <> · 修改 {formatRelativeTime(post.updated_at)} )} {post.views_count !== undefined && post.views_count > 0 && ( <> · {formatNumber(post.views_count)} 浏览 )} {currentUser?.id === post.author?.id && ( 编辑 {/* 删除按钮 - 只对帖子作者显示 */} 删除 )} {/* 底部操作栏 - QQ频道风格 */} {post.likes_count > 0 ? formatNumber(post.likes_count) : '点赞'} {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} {post.shares_count > 0 ? formatNumber(post.shares_count) : '分享'} {post.is_favorited ? '已收藏' : '收藏'} {/* 评论标题 - 现代化设计 */} 评论 {post.comments_count} ); }, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]); // 回复评论 const [replyingTo, setReplyingTo] = useState(null); const handleReply = (comment: Comment) => { setReplyingTo(comment); // 可以在这里添加聚焦输入框的逻辑 }; const handleCancelReply = () => { setReplyingTo(null); }; // 加载更多回复 const handleLoadMoreReplies = async (commentId: string) => { try { // 找到当前评论 const comment = comments.find(c => c.id === commentId); if (!comment) return; // 计算下一页的起始位置 const currentRepliesCount = comment.replies?.length || 0; // 调用API获取更多回复 const response = await commentService.getFlatReplies(commentId, 1, currentRepliesCount + 10); // 更新评论列表 setComments(prev => prev.map(c => { if (c.id === commentId) { return { ...c, replies: response.list, }; } return c; })); } catch (error) { console.error('加载更多回复失败:', error); } }; // 渲染评论 - 带楼层号 const renderComment = ({ item, index }: { item: Comment; index: number }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; // 判断当前用户是否为评论作者 const isCommentAuthor = currentUser?.id === authorId; return ( handleLikeComment(item)} onReply={() => handleReply(item)} onUserPress={() => authorId && handleUserPress(authorId)} floorNumber={index + 1} isAuthor={authorId === post?.user_id} onReplyPress={handleReply} allReplies={allReplies} onLoadMoreReplies={handleLoadMoreReplies} isCommentAuthor={isCommentAuthor} onDelete={handleDeleteComment} onImagePress={handleImagePress} currentUserId={currentUser?.id} /> ); }; // 渲染空评论 - 现代化设计 const renderEmptyComments = () => ( 还没有评论 成为第一个评论的人吧~ ); // 渲染评论输入框 const renderCommentInput = () => ( {/* 回复提示 */} {replyingTo && ( 回复 {replyingTo.author.nickname} )} {/* 已选图片预览 */} {commentImages.length > 0 && ( {commentImages.map((image, index) => ( {image.uploading && ( )} handleRemoveCommentImage(index)} > ))} )} {/* 图片选择按钮 */} = 3} > = 3 ? colors.text.disabled : colors.text.secondary} /> 0) && styles.sendButtonDisabled ]} onPress={handleSendComment} disabled={!(commentText.trim() || commentImages.length > 0)} > 0) ? colors.primary.main : colors.text.disabled} /> ); // 渲染评论列表 const renderCommentsList = () => ( item.id} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} onScrollToIndexFailed={() => { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); }} refreshControl={ } onEndReached={loadMoreComments} onEndReachedThreshold={0.3} ListFooterComponent={ isCommentsLoading ? ( ) : hasMoreComments ? ( 加载更多评论 ) : (comments?.length ?? 0) > 0 ? ( 没有更多评论了 ) : null } /> ); // 渲染右侧边栏(桌面端) const renderSidebar = () => ( 热门帖子 {/* 这里可以添加热门帖子列表 */} 热门内容即将推出 ); if (loading) { return ; } if (!post) { return ( ); } // 桌面端双栏布局 if (useDualColumnLayout) { return ( } > {renderPostHeader()} {renderCommentsList()} {renderCommentInput()} {/* 图片预览 ImageGallery */} ({ id: img.id || img.url || String(Math.random()), url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={() => setShowImageModal(false)} enableSave /> ); } // 移动端单栏布局 return ( item.id} ListHeaderComponent={renderPostHeader} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} onScrollToIndexFailed={() => { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); }} refreshControl={ } onEndReached={loadMoreComments} onEndReachedThreshold={0.3} ListFooterComponent={ isCommentsLoading ? ( ) : hasMoreComments ? ( 加载更多评论 ) : (comments?.length ?? 0) > 0 ? ( 没有更多评论了 ) : null } /> {/* 评论输入框 - 跟随键盘 */} {renderCommentInput()} {/* 图片预览 ImageGallery */} ({ id: img.id || img.url || String(Math.random()), url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={() => setShowImageModal(false)} enableSave /> ); }; const styles = StyleSheet.create({ flex: { flex: 1, }, container: { flex: 1, backgroundColor: colors.background.default, }, // Header 样式 headerContainer: { flexDirection: 'row', alignItems: 'center', flex: 1, marginLeft: -8, }, headerAvatarWrapper: { marginRight: spacing.sm, }, headerUserInfo: { flex: 1, justifyContent: 'center', }, headerNickname: { fontSize: fontSizes.md, fontWeight: '600', color: colors.text.primary, }, headerRightContainer: { flexDirection: 'row', alignItems: 'center', marginRight: spacing.sm, }, // 帖子容器 postContainer: { backgroundColor: colors.background.paper, paddingBottom: spacing.md, }, // 关注按钮样式 followButton: { paddingHorizontal: spacing.md, paddingVertical: 4, borderRadius: borderRadius.lg, minWidth: 64, alignItems: 'center', justifyContent: 'center', }, followButtonPrimary: { backgroundColor: colors.primary.main, }, followButtonOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.divider, }, followButtonMutual: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.divider, }, followButtonLoading: { opacity: 0.7, }, followButtonText: { fontSize: fontSizes.sm, fontWeight: '600', }, followButtonTextPrimary: { color: colors.text.inverse, }, followButtonTextOutline: { color: colors.text.secondary, }, // 徽章样式 badge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: borderRadius.sm, marginRight: spacing.xs, }, authorBadge: { backgroundColor: colors.primary.main, }, adminBadge: { backgroundColor: colors.error.main, }, badgeText: { color: colors.text.inverse, fontSize: fontSizes.xs, fontWeight: '600', }, // 标题和内容 titleContainer: { marginBottom: spacing.lg, }, title: { fontWeight: '800', color: colors.text.primary, letterSpacing: -0.3, }, contentContainer: { marginBottom: spacing.lg, }, content: { color: colors.text.primary, letterSpacing: 0.2, }, // 发帖时间和浏览量 postMetaInfo: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing.sm, }, metaInfoMain: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', flex: 1, minWidth: 0, }, metaInfoText: { fontSize: fontSizes.sm, color: colors.text.hint, }, metaInfoDot: { marginHorizontal: spacing.sm, color: colors.text.hint, fontSize: fontSizes.sm, fontWeight: '700', }, deleteButtonInline: { flexDirection: 'row', alignItems: 'center', marginLeft: spacing.sm, padding: spacing.xs, }, deleteButtonText: { marginLeft: 2, fontSize: fontSizes.sm, }, metaActions: { flexDirection: 'row', alignItems: 'center', marginLeft: spacing.sm, flexShrink: 0, }, editButtonInline: { flexDirection: 'row', alignItems: 'center', padding: spacing.xs, }, editButtonText: { marginLeft: 2, fontSize: fontSizes.sm, }, imagesContainer: { flexDirection: 'row', flexWrap: 'wrap', marginTop: spacing.sm, marginHorizontal: -spacing.xs, }, imageWrapper: { width: 100, // 动态计算 height: 100, // 动态计算 margin: spacing.xs, borderRadius: borderRadius.md, overflow: 'hidden', }, postImage: { width: '100%', height: '100%', }, voteContainer: { marginVertical: spacing.md, }, voteLoadingPlaceholder: { backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, padding: spacing.xl, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.divider, }, voteLoadingText: { marginTop: spacing.sm, }, actionBar: { flexDirection: 'row', justifyContent: 'space-around', paddingVertical: spacing.md, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, backgroundColor: colors.background.paper, }, actionButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.lg, }, actionText: { fontSize: fontSizes.sm, marginLeft: 6, fontWeight: '500', }, commentTitle: { flexDirection: 'row', alignItems: 'center', }, commentTitleLeft: { flexDirection: 'row', alignItems: 'center', }, commentTitleIconContainer: { borderRadius: borderRadius.md, backgroundColor: colors.primary.main, justifyContent: 'center', alignItems: 'center', marginRight: spacing.sm, }, commentTitleText: { fontWeight: '700', color: colors.text.primary, }, commentCountBadge: { backgroundColor: colors.primary.light + '25', paddingHorizontal: spacing.sm, paddingVertical: 2, borderRadius: borderRadius.md, marginLeft: spacing.sm, minWidth: 24, alignItems: 'center', }, commentCountText: { fontSize: fontSizes.sm, fontWeight: '700', color: colors.primary.main, }, inputContainer: { backgroundColor: colors.background.paper, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, replyingToContainer: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs, paddingHorizontal: spacing.xs, }, replyingToName: { color: colors.primary.main, fontWeight: '600', }, cancelReplyButton: { marginLeft: spacing.sm, padding: 2, }, inputWrapper: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background.default, borderRadius: 20, paddingHorizontal: spacing.md, }, input: { flex: 1, minHeight: 40, maxHeight: 100, color: colors.text.primary, paddingVertical: spacing.sm, }, sendButton: { padding: spacing.sm, }, sendButtonDisabled: { opacity: 0.5, }, imagePickerButton: { padding: spacing.sm, marginRight: spacing.xs, }, commentImagesPreview: { maxHeight: 80, marginBottom: spacing.xs, }, commentImageWrapper: { width: 60, height: 60, marginRight: spacing.sm, borderRadius: borderRadius.sm, overflow: 'hidden', position: 'relative', }, commentImageThumbnail: { width: 60, height: 60, borderRadius: borderRadius.sm, }, uploadingOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.4)', justifyContent: 'center', alignItems: 'center', borderRadius: borderRadius.sm, }, removeImageButton: { position: 'absolute', top: -6, right: -6, backgroundColor: colors.background.paper, borderRadius: 9, }, modalContainer: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.9)', justifyContent: 'center', alignItems: 'center', }, closeButton: { position: 'absolute', top: 40, right: 20, zIndex: 1, }, fullImage: { width: '100%', height: '100%', }, imageCounter: { position: 'absolute', bottom: 40, backgroundColor: 'rgba(0, 0, 0, 0.6)', paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.md, }, // 空评论状态样式 emptyCommentsContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl * 2, paddingHorizontal: spacing.lg, }, emptyCommentsIconWrapper: { width: 72, height: 72, borderRadius: 36, backgroundColor: colors.primary.light + '18', alignItems: 'center', justifyContent: 'center', marginBottom: spacing.lg, }, emptyCommentsTitle: { fontSize: fontSizes.md, fontWeight: '500', color: colors.text.primary, marginBottom: spacing.xs, }, emptyCommentsSubtitle: { fontSize: fontSizes.sm, textAlign: 'center', }, // 侧边栏样式 sidebar: { backgroundColor: colors.background.paper, borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: colors.divider, }, sidebarContent: { flex: 1, }, sidebarTitle: { fontWeight: '700', color: colors.text.primary, marginBottom: spacing.md, }, sidebarPlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl, }, sidebarPlaceholderText: { marginTop: spacing.md, textAlign: 'center', }, // 评论加载更多样式 commentsLoadingFooter: { paddingVertical: spacing.md, alignItems: 'center', justifyContent: 'center', }, loadMoreButton: { paddingVertical: spacing.md, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background.paper, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, noMoreComments: { textAlign: 'center', paddingVertical: spacing.md, }, });