/** * 帖子详情页 PostDetailScreen - QQ频道风格(响应式版本) * 威友 - 显示完整帖子内容和评论 * 桌面端使用双栏布局,移动端使用单栏布局 */ 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors, } from '../../theme'; import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/auth'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog, ShareSheet } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; import { handleError } from '@/services/core'; import * as hrefs from '../../navigation/hrefs'; function togglePostField( post: Post, field: 'is_liked' | 'is_favorited', countField: 'likes_count' | 'favorites_count', ): Post { const oldValue = post[field]; const oldCount = post[countField]; return { ...post, [field]: !oldValue, [countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1, }; } function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] { return comments.map(c => { if (c.id === commentId) { return { ...c, is_liked: isLiked, likes_count: likesCount }; } if (c.replies?.length) { return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) }; } return c; }); } export const PostDetailScreen: React.FC = () => { const colors = useAppColors(); const styles = useMemo(() => createPostDetailStyles(colors), [colors]); const navigation = useNavigation(); const router = useRouter(); const insets = useSafeAreaInsets(); const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{ postId?: string; scrollToComments?: string; }>(); const postId = postIdParam || ''; const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true'; // 使用响应式 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 [isPostInitialLoading, setIsPostInitialLoading] = useState(true); const [isPostRefreshing, setIsPostRefreshing] = useState(false); // 使用游标分页 Hook 管理评论列表 const { list: paginatedComments, isLoading: isCommentsLoading, isInitialLoading: isCommentsInitialLoading, isLoadingMore: isCommentsLoadingMore, 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 } ); const getCommentId = useCallback((comment: Comment) => String(comment.id), []); const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => { const previous = Array.isArray(previousInput) ? previousInput : []; const incoming = Array.isArray(incomingInput) ? incomingInput : []; if (incoming.length === 0) return previous; if (previous.length === 0) return incoming; const previousSet = new Set(previous.map(getCommentId)); const incomingFirstId = getCommentId(incoming[0]); if (!previousSet.has(incomingFirstId)) { return [...previous, ...incoming]; } const merged = [...previous]; const indexById = new Map(); for (let i = 0; i < merged.length; i += 1) { indexById.set(getCommentId(merged[i]), i); } for (const comment of incoming) { const id = getCommentId(comment); const existingIndex = indexById.get(id); if (existingIndex === undefined) { indexById.set(id, merged.length); merged.push(comment); } else { merged[existingIndex] = comment; } } return merged; }, [getCommentId]); const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => { const previous = Array.isArray(previousInput) ? previousInput : []; const latest = Array.isArray(latestInput) ? latestInput : []; if (latest.length === 0) return previous; if (previous.length === 0) return latest; const latestSet = new Set(latest.map(getCommentId)); const tail = previous.filter((item) => !latestSet.has(getCommentId(item))); return [...latest, ...tail]; }, [getCommentId]); // 切换帖子时,先清空上一帖评论,避免跨帖残留。 useEffect(() => { setComments([]); }, [postId]); // 同步分页评论到本地状态(增量合并,避免全量替换) useEffect(() => { setComments((prev) => { if (isCommentsRefreshing) { return mergeCommentsRefreshWindow(prev, paginatedComments); } return mergeCommentsById(prev, paginatedComments); }); }, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]); useEffect(() => { if (commentsError) { console.warn('[PostPerf] comments request error', { postId, error: commentsError }); } }, [commentsError, postId]); 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 [reportDialogVisible, setReportDialogVisible] = useState(false); const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null); // 分享面板状态 const [shareSheetVisible, setShareSheetVisible] = useState(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) { setIsPostInitialLoading(false); return; } try { // 使用 ProcessPostUseCase 获取帖子详情 const postData = await postSyncService.fetchPostById(postId); if (postData) { // 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性 setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { setIsFollowing(postData.author.is_following || false); setIsFollowingMe(postData.author.is_following_me || false); } if (!hasRecordedView.current) { hasRecordedView.current = true; postService.recordView(postId).catch(err => { console.error('记录浏览量失败:', err); }); } if (postData.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.is_following || false); setIsFollowingMe(updatedPost.author.is_following_me || false); } } } // 加载评论(使用游标分页刷新) await refreshComments(); } catch (error) { handleError(error, { context: '加载帖子详情' }); } finally { setIsPostInitialLoading(false); } }, [postId]); // 首次加载时记录浏览量 useEffect(() => { loadPostDetail(true); }, [loadPostDetail]); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { loadPostDetail(false); }); return unsubscribe; }, [navigation, loadPostDetail]); // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } }, [shouldScrollToComments, isPostInitialLoading, comments?.length]); // 动态设置导航头部 useEffect(() => { if (!post?.author) return; const author = post.author; const handleBackPress = () => { if (router.canGoBack()) { router.back(); return; } router.replace(hrefs.hrefHome()); }; navigation.setOptions({ // 与页面容器 background.default 一致,并去掉原生 header 底部分隔阴影,避免「一条线」 headerStyle: { backgroundColor: colors.background.default }, headerShadowVisible: false, headerBackVisible: false, headerTitleAlign: 'left', headerLeft: () => ( ), headerTitle: () => ( handleUserPress(author.id)} style={styles.headerAvatarWrapper} > handleUserPress(author.id)}> {author.nickname && author.nickname.length > 10 ? `${author.nickname.slice(0, 10)}...` : author.nickname} ), headerRight: () => ( {renderFollowButton()} ), }); }, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation, colors.background.default, styles]); // 监听键盘事件 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 () => { const startedAt = Date.now(); setIsPostRefreshing(true); await loadPostDetail(); console.log('[PostPerf] detail refresh', { postId, costMs: Date.now() - startedAt, comments: comments.length, }); setIsPostRefreshing(false); }, [comments.length, loadPostDetail, postId, refreshComments]); const handleLoadMoreComments = useCallback(async () => { const startedAt = Date.now(); await loadMoreComments(); console.log('[PostPerf] detail loadMoreComments', { postId, costMs: Date.now() - startedAt, totalItems: comments.length, }); }, [comments.length, loadMoreComments, postId]); 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 getPostEditedDisplayAt = ( createdAt?: string | null, contentEditedAt?: string | null, updatedAt?: string | null, ): string | null => { if (contentEditedAt) { const c = new Date(createdAt || '').getTime(); const e = new Date(contentEditedAt).getTime(); if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; return null; } if (createdAt && updatedAt) { const c = new Date(createdAt).getTime(); const u = new Date(updatedAt).getTime(); if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; } return null; }; // 格式化数字 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 originalPost = post; setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null); try { if (originalPost.is_liked) { await postSyncService.unlikePost(post.id); } else { await postSyncService.likePost(post.id); } } catch (error) { handleError(error, { context: '点赞' }); setPost(originalPost); } }, [post]); const handleFavorite = useCallback(async () => { if (!post) return; const originalPost = post; setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null); try { if (originalPost.is_favorited) { await postSyncService.unfavoritePost(post.id); } else { await postSyncService.favoritePost(post.id); } } catch (error) { handleError(error, { context: '收藏' }); setPost(originalPost); } }, [post]); // 分享帖子 - 打开分享面板 const handleShare = useCallback(async () => { if (!post?.id) return; setShareSheetVisible(true); }, [post?.id]); // 投票处理函数 const handleVote = useCallback(async (optionId: string) => { if (!post || !voteResult || isVoteLoading) return; // 保存旧状态用于回滚 const oldVoteResult = { ...voteResult }; // 乐观更新 setVoteResult((prev: VoteResultDTO | null) => { if (!prev) return null; return { ...prev, has_voted: true, voted_option_id: optionId, total_votes: prev.total_votes + 1, options: prev.options.map((opt: VoteOptionDTO) => 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) { handleError(error, { context: '投票' }); 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: VoteResultDTO | null) => { 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: VoteOptionDTO) => 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) { handleError(error, { context: '取消投票' }); 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 postSyncService.deletePost(post.id); Alert.alert('删除成功', '帖子已删除', [ { text: '确定', onPress: () => router.back(), }, ]); } catch (error) { console.error('删除帖子失败:', error); Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); } finally { setIsDeleting(false); } }, }, ], ); }, [post, isDeleting, currentUser?.id, router]); const handleEditPost = useCallback(() => { if (!post) return; router.push(hrefs.hrefCreatePost('edit', post.id)); }, [router, 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); setIsComposerVisible(false); // 更新帖子评论数 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 { id, is_liked, likes_count } = comment; const oldIsLiked = is_liked ?? false; const newIsLiked = !oldIsLiked; const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1); const originalComments = comments; setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount)); try { const success = newIsLiked ? await commentService.likeComment(id) : await commentService.unlikeComment(id); if (!success) { throw new Error('点赞操作失败'); } } catch (error) { handleError(error, { context: '评论点赞' }); setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count)); } }; // 删除评论 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) => { router.push(hrefs.hrefUserProfile(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 && ( )} {/* 所属板块 */} {!!post.channel?.name && ( {post.channel.name} )} {/* 发帖时间和浏览量 - 放在图片下方 */} 发布 {formatRelativeTime(post.created_at)} {(() => { const editedAt = getPostEditedDisplayAt( post.created_at, post.content_edited_at, post.updated_at, ); return editedAt ? ( <> · 修改 {formatRelativeTime(editedAt)} ) : null; })()} {post.views_count !== undefined && post.views_count > 0 && ( <> · {formatNumber(post.views_count)} 浏览 )} {!!currentUser && currentUser.id === post.author?.id && ( 编辑 {/* 删除按钮 - 只对帖子作者显示 */} 删除 )} {/* 举报按钮 - 只对已登录且非帖子作者显示 */} {!!currentUser && currentUser.id !== post.author?.id && ( { setReportTarget({ type: 'post', id: post.id }); setReportDialogVisible(true); }} > 举报 )} {/* 评论标题 - 类似图片中的样式 */} 评论 {post.comments_count || 0} ); }, [post, postImages, currentUser?.id, isDeleting, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]); // 回复评论 const [replyingTo, setReplyingTo] = useState(null); const [isComposerVisible, setIsComposerVisible] = useState(false); const handleReply = (comment: Comment) => { setReplyingTo(comment); setIsComposerVisible(true); }; 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 handleReportComment = useCallback((comment: Comment) => { setReportTarget({ type: 'comment', id: comment.id }); setReportDialogVisible(true); }, []); // 渲染评论 - 带楼层号 const commentKeyExtractor = useCallback((item: Comment) => item.id, []); const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; // 判断当前用户是否为评论作者 const isCommentAuthor = currentUser?.id === authorId; return ( 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} onReport={handleReportComment} /> ); }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]); // 渲染空评论 - 类似图片中的样式 const renderEmptyComments = useCallback(() => ( {/* 四个方块图标 */} 这里空空如也,邀请好友来互动吧! ), []); const openComposer = () => { setIsComposerVisible(true); }; const closeComposer = () => { setIsComposerVisible(false); Keyboard.dismiss(); }; const renderCommentInput = () => ( {/* 回复提示 */} {replyingTo && !isComposerVisible && ( 回复 {replyingTo.author.nickname} )} {!isComposerVisible ? ( /* Collapsed state - simple input trigger */ {replyingTo ? `回复 ${replyingTo.author.nickname}...` : '来说点什么吧!'} {formatNumber(post?.likes_count || 0)} {formatNumber(post?.favorites_count || 0)} {formatNumber(post?.shares_count || 0)} ) : ( /* Expanded state - full composer */ {/* Reply hint */} {replyingTo && ( 回复 {replyingTo.author.nickname} )} {/* Text Input */} {/* Image preview */} {commentImages.length > 0 && ( {commentImages.map((image, index) => ( {image.uploading && ( )} handleRemoveCommentImage(index)} > ))} )} {/* Toolbar */} = 3} > = 3 ? colors.text.disabled : colors.text.secondary} /> {}} > {}} > {commentText.length}/500 0) && styles.expandedSendButtonDisabled ]} onPress={handleSendComment} disabled={!(commentText.trim() || commentImages.length > 0)} > 发送 )} ); // 渲染评论列表 const renderCommentsList = () => ( { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); }} refreshControl={ } onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} initialNumToRender={10} maxToRenderPerBatch={10} updateCellsBatchingPeriod={60} windowSize={7} removeClippedSubviews ListFooterComponent={ isCommentsInitialLoading ? ( ) : isCommentsLoadingMore ? ( ) : hasMoreComments ? ( 加载更多评论 ) : (comments?.length ?? 0) > 0 ? ( 没有更多评论了 ) : null } /> ); // 渲染右侧边栏(桌面端) const renderSidebar = () => ( 热门帖子 {/* 这里可以添加热门帖子列表 */} 热门内容即将推出 ); if (isPostInitialLoading) { 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 ( { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); }} refreshControl={ } onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} initialNumToRender={10} maxToRenderPerBatch={10} updateCellsBatchingPeriod={60} windowSize={7} removeClippedSubviews ListFooterComponent={ isCommentsInitialLoading ? ( ) : isCommentsLoadingMore ? ( ) : 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 /> {/* 举报对话框 */} { setReportDialogVisible(false); setReportTarget(null); }} onSuccess={() => { setReportTarget(null); }} /> {/* 分享面板 */} { setShareSheetVisible(false); }} onShareAction={(actionKey) => { if (post) { (async () => { try { const res = await postService.sharePost(post.id, { channel: actionKey }); if (res.ok && res.shares_count != null) { setPost((prev) => prev ? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! } : null ); postSyncService.applyShareCountUpdate(post.id, res.shares_count!); } } catch (error) { console.error('上报分享次数失败:', error); } })(); } }} /> ); }; function createPostDetailStyles(colors: AppColors) { return StyleSheet.create({ flex: { flex: 1, }, container: { flex: 1, backgroundColor: colors.background.default, }, // Header 样式 headerContainer: { flexDirection: 'row', alignItems: 'center', flex: 1, marginLeft: spacing.sm, }, 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, }, headerBackButton: { paddingHorizontal: spacing.xs, paddingVertical: spacing.xs, marginLeft: spacing.xs, 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: 'flex-start', 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, }, reportButtonInline: { flexDirection: 'row', alignItems: 'center', marginLeft: spacing.sm, padding: spacing.xs, }, reportButtonText: { 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, }, commentSectionHeader: { flexDirection: 'column', alignItems: 'flex-start', paddingTop: spacing.lg, }, commentSectionTitleRow: { flexDirection: 'row', alignItems: 'center', }, commentSectionTitle: { fontWeight: '600', color: colors.text.primary, letterSpacing: 0, }, commentSectionCount: { marginLeft: spacing.xs, fontSize: fontSizes.sm, color: colors.text.hint, }, commentSectionUnderline: { width: 28, height: 3, backgroundColor: colors.text.primary, marginTop: spacing.xs, borderRadius: borderRadius.sm, }, 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, }, inputTrigger: { flex: 1, backgroundColor: colors.background.default, borderRadius: 18, paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2, minHeight: 38, justifyContent: 'center', }, inputTriggerText: { fontSize: fontSizes.sm, color: colors.text.hint, }, bottomComposerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs + 2, }, bottomActionItem: { alignItems: 'center', justifyContent: 'center', paddingVertical: 2, paddingHorizontal: spacing.sm + 1, marginLeft: spacing.xs, }, bottomActionCount: { marginTop: 1, fontSize: fontSizes.xs, color: colors.text.secondary, lineHeight: fontSizes.xs + 2, fontWeight: '500', }, 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, }, // Expanded composer styles expandedComposer: { backgroundColor: colors.background.paper, }, expandedReplyHint: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm, }, expandedTextInput: { minHeight: 80, maxHeight: 160, fontSize: fontSizes.md, color: colors.text.primary, paddingVertical: spacing.sm, textAlignVertical: 'top', lineHeight: 22, }, expandedToolbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: spacing.sm, paddingTop: spacing.sm, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, expandedToolbarLeft: { flexDirection: 'row', alignItems: 'center', flex: 1, }, expandedToolbarItem: { padding: spacing.xs, marginRight: spacing.sm, }, expandedCharCount: { marginLeft: 'auto', marginRight: spacing.sm, }, expandedSendButton: { backgroundColor: colors.primary.main, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: borderRadius.md, minWidth: 60, alignItems: 'center', }, expandedSendButtonDisabled: { backgroundColor: colors.text.disabled, }, expandedSendButtonText: { color: colors.text.inverse, fontSize: fontSizes.sm, fontWeight: '600', }, 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.5, }, emptyCommentsGrid: { flexDirection: 'row', flexWrap: 'wrap', width: 48, height: 48, marginBottom: spacing.md, }, emptyCommentsGridItem: { width: 20, height: 20, backgroundColor: colors.background.disabled, margin: 2, borderRadius: borderRadius.sm, }, emptyCommentsGridItemActive: { backgroundColor: colors.text.hint, }, emptyCommentsSubtitle: { fontSize: fontSizes.sm, textAlign: 'center', color: colors.text.hint, }, // 侧边栏样式 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', }, // 所属板块标签 channelTagContainer: { flexDirection: 'row', alignItems: 'center', }, channelTag: { flexDirection: 'row', alignItems: 'center', maxWidth: 140, paddingHorizontal: 6, paddingVertical: 2, borderRadius: borderRadius.sm, backgroundColor: `${colors.primary.main}12`, gap: 4, }, channelTagText: { fontSize: fontSizes.xs, color: colors.primary.main, fontWeight: '600', flexShrink: 1, }, // 评论加载更多样式 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, }, }); }