/** * 首页 HomeScreen * 胡萝卜BBS - 首页展示 * 支持列表和多列网格模式(响应式布局) */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, FlatList, ScrollView, StyleSheet, RefreshControl, StatusBar, TouchableOpacity, NativeScrollEvent, NativeSyntheticEvent, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Post } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; const TABS = ['推荐', '关注', '热门', '最新']; const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline']; const DEFAULT_PAGE_SIZE = 20; const SCROLL_BOTTOM_THRESHOLD = 240; const LOAD_MORE_COOLDOWN_MS = 800; const SWIPE_TRANSLATION_THRESHOLD = 40; const SWIPE_COOLDOWN_MS = 300; const MOBILE_TAB_BAR_HEIGHT = 64; const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_FAB_GAP = 12; type ViewMode = 'list' | 'grid'; export const HomeScreen: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 使用响应式 hook const { width, isMobile, isTablet, isDesktop, isWideScreen, breakpoint, orientation, isLandscape } = useResponsive(); // 响应式间距 const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 }); const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const [activeIndex, setActiveIndex] = useState(0); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [viewMode, setViewMode] = useState('list'); // 图片查看器状态 const [showImageViewer, setShowImageViewer] = useState(false); const [postImages, setPostImages] = useState([]); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 const postIdsRef = React.useRef>(new Set()); const inFlightRequestKeysRef = React.useRef>(new Set()); const lastLoadMoreTriggerAtRef = useRef(0); const lastSwipeAtRef = useRef(0); // 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题 const pageRef = useRef(page); const loadingMoreRef = useRef(loadingMore); const hasMoreRef = useRef(hasMore); pageRef.current = page; loadingMoreRef.current = loadingMore; hasMoreRef.current = hasMore; // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { if (isWideScreen || width >= 1440) return 4; if (isDesktop || width >= 1024) return 3; if (isTablet || width >= 768) return 2; return 2; // 移动端瀑布流保持2列 }, [width, isTablet, isDesktop, isWideScreen]); // 列表模式下始终使用单列,宽屏下居中显示 const useMultiColumnList = useMemo(() => { return false; // 改为始终返回false,使用单列布局 }, []); // 宽屏下内容最大宽度 const contentMaxWidth = useMemo(() => { if (isWideScreen) return 800; if (isDesktop) return 720; if (isTablet) return 640; return width; // 移动端使用全宽 }, [width, isTablet, isDesktop, isWideScreen]); // 列表模式横向内边距:移动端适当收窄,减少两侧空白 const listHorizontalPadding = useMemo(() => { if (isMobile) { return Math.max(6, responsivePadding - responsiveGap); } return responsivePadding; }, [isMobile, responsivePadding, responsiveGap]); // 列表模式卡片宽度:宽屏限宽并居中,移动端占用更多可用宽度 const listItemWidth = useMemo(() => { const availableWidth = Math.max(0, width - listHorizontalPadding * 2); if (isDesktop || isWideScreen) { return Math.min(contentMaxWidth, availableWidth); } return availableWidth; }, [width, listHorizontalPadding, isDesktop, isWideScreen, contentMaxWidth]); const floatingButtonBottom = useMemo(() => { if (!isMobile) { return undefined; } return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP; }, [isMobile, insets.bottom]); const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => { if (incomingPosts.length === 0) return prevPosts; const seenIds = new Set(prevPosts.map(item => item.id)); const dedupedIncoming = incomingPosts.filter(item => { if (seenIds.has(item.id)) return false; seenIds.add(item.id); return true; }); return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts; }, []); const uniquePostsById = useCallback((items: Post[]) => { if (items.length <= 1) return items; const map = new Map(); for (const item of items) { map.set(item.id, item); } return Array.from(map.values()); }, []); const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => { switch (activeIndex) { case 0: return 'recommend'; case 1: return 'follow'; case 2: return 'hot'; case 3: return 'latest'; default: return 'recommend'; } }; // 加载帖子列表 const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => { const postType = getPostType(); const requestKey = `${postType}:${pageNum}`; if (inFlightRequestKeysRef.current.has(requestKey)) { return; } try { inFlightRequestKeysRef.current.add(requestKey); if (isRefresh) { setRefreshing(true); } else if (pageNum === 1) { setLoading(true); } else { setLoadingMore(true); } const response = await fetchPosts(postType, pageNum); const newPosts = response.list || []; if (isRefresh) { setPosts(uniquePostsById(newPosts)); setPage(1); } else if (pageNum === 1) { setPosts(uniquePostsById(newPosts)); setPage(1); } else { setPosts(prev => appendUniquePosts(prev, newPosts)); setPage(pageNum); } const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false; const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE); setHasMore(hasMoreByPage || hasMoreBySize); } catch (error) { console.error('Failed to load posts:', error); } finally { inFlightRequestKeysRef.current.delete(requestKey); setLoading(false); setRefreshing(false); setLoadingMore(false); } }, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]); // 切换Tab时重新加载 useEffect(() => { loadPosts(1, true); }, [activeIndex]); // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) useEffect(() => { if (posts.length === 0) return; // 更新 postIdsRef const currentPostIds = new Set(posts.map(p => p.id)); postIdsRef.current = currentPostIds; // 从 store 中找到对应的帖子并同步状态 let hasChanges = false; const updatedPosts = posts.map(localPost => { const storePost = storePosts.find(sp => sp.id === localPost.id); if (storePost && ( storePost.is_liked !== localPost.is_liked || storePost.is_favorited !== localPost.is_favorited || storePost.likes_count !== localPost.likes_count || storePost.favorites_count !== localPost.favorites_count )) { hasChanges = true; return { ...localPost, is_liked: storePost.is_liked, is_favorited: storePost.is_favorited, likes_count: storePost.likes_count, favorites_count: storePost.favorites_count, }; } return localPost; }); if (hasChanges) { setPosts(updatedPosts); } }, [storePosts]); // 下拉刷新 const onRefresh = useCallback(() => { loadPosts(1, true); }, [loadPosts]); // 上拉加载更多 const onEndReached = useCallback(() => { if (!loadingMoreRef.current && hasMoreRef.current) { loadPosts(pageRef.current + 1); } }, [loadPosts]); const onWaterfallScroll = useCallback((event: NativeSyntheticEvent) => { if (loadingMoreRef.current || !hasMoreRef.current) return; const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height); const now = Date.now(); if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) { if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) { return; } lastLoadMoreTriggerAtRef.current = now; loadPosts(pageRef.current + 1); } }, [loadPosts]); // 切换视图模式 const toggleViewMode = () => { setViewMode(prev => prev === 'list' ? 'grid' : 'list'); }; // 切换Tab(手势/点击共用) const changeTab = useCallback((nextIndex: number) => { if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) { return; } setActiveIndex(nextIndex); }, [activeIndex]); const handleSwipeTabChange = useCallback((translationX: number) => { setActiveIndex(prev => ( translationX < 0 ? Math.min(prev + 1, TABS.length - 1) : Math.max(prev - 1, 0) )); }, []); const swipeGesture = useMemo(() => ( Gesture.Pan() .runOnJS(true) .activeOffsetX([-15, 15]) .failOffsetY([-20, 20]) .onEnd((event) => { const now = Date.now(); if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) { return; } if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) { return; } lastSwipeAtRef.current = now; handleSwipeTabChange(event.translationX); }) ), [handleSwipeTabChange]); // 跳转到搜索页 const handleSearchPress = () => { navigation.navigate('Search'); }; // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { navigation.getParent()?.navigate('PostDetail', { postId, scrollToComments }); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { navigation.getParent()?.navigate('UserProfile', { userId }); }; // 点赞帖子 const handleLike = (post: Post) => { if (post.is_liked) { unlikePost(post.id); } else { likePost(post.id); } }; // 收藏帖子 const handleBookmark = (post: Post) => { if (post.is_favorited) { unfavoritePost(post.id); } else { favoritePost(post.id); } }; // 分享帖子 const handleShare = (post: Post) => { void post; }; // 删除帖子 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); if (success) { // 从列表中移除已删除的帖子 setPosts(prev => prev.filter(p => p.id !== postId)); } else { console.error('删除帖子失败'); } } catch (error) { console.error('删除帖子失败:', error); throw error; // 重新抛出错误,让 PostCard 处理错误提示 } }; // 处理图片点击 - 打开图片查看器 const handleImagePress = (images: ImageGridItem[], index: number) => { setPostImages(images); setSelectedImageIndex(index); setShowImageViewer(true); }; // 跳转到发帖页面 const handleCreatePost = () => { navigation.getParent()?.navigate('CreatePost'); }; // 渲染帖子卡片(列表模式) const renderPostList = ({ item }: { item: Post }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( handlePostPress(item.id)} onUserPress={() => authorId && handleUserPress(authorId)} onLike={() => handleLike(item)} onComment={() => handlePostPress(item.id, true)} onBookmark={() => handleBookmark(item)} onShare={() => handleShare(item)} onImagePress={(images, index) => handleImagePress(images, index)} onDelete={() => handleDeletePost(item.id)} isPostAuthor={isPostAuthor} /> ); }; // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { const hasImage = post.images && post.images.length > 0; const hasTitle = !!post.title; const hasContent = !!post.content; let height = 0; // 图片区域高度(如果有图) if (hasImage) { // 使用帖子 ID 生成一致的宽高比 const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0); const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; const aspectRatio = aspectRatios[hash % aspectRatios.length]; height += columnWidth / aspectRatio; } else { // 无图帖子显示正文预览区域 if (hasContent) { // 根据内容长度估算高度(每行约20像素,最多6行) const contentLength = post.content?.length || 0; const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20))); height += 16 + estimatedLines * 20; // padding + 文本高度 } } // 标题高度 if (hasTitle) { const titleLines = hasImage ? 2 : 3; height += 8 + titleLines * 20; // paddingTop + 文本高度 } // 底部信息栏高度 height += 40; // 用户信息 + 点赞数 // 间距 height += 2; // marginBottom return height; }; // 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀 const distributePostsToColumns = useMemo(() => { const columns: Post[][] = Array.from({ length: gridColumns }, () => []); const columnHeights: number[] = Array(gridColumns).fill(0); // 计算单列宽度 const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; posts.forEach((post) => { const postHeight = estimatePostHeight(post, columnWidth); // 找到当前高度最小的列 const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights)); columns[minHeightIndex].push(post); columnHeights[minHeightIndex] += postHeight; }); return columns; }, [posts, gridColumns, width, responsiveGap, responsivePadding]); // 渲染单列帖子 const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( {column.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( handlePostPress(post.id)} onUserPress={() => authorId && handleUserPress(authorId)} onLike={() => handleLike(post)} onComment={() => handlePostPress(post.id, true)} onBookmark={() => handleBookmark(post)} onShare={() => handleShare(post)} onImagePress={(images, index) => handleImagePress(images, index)} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} /> ); })} ); // 渲染响应式网格布局(平板/桌面端使用多列) const renderResponsiveGrid = () => { if (isMobile && !isTablet) { // 移动端使用瀑布流布局(2列) return ( } > {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} ); } // 平板/桌面端使用 ResponsiveGrid 组件 return ( {posts.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( handlePostPress(post.id)} onUserPress={() => authorId && handleUserPress(authorId)} onLike={() => handleLike(post)} onComment={() => handlePostPress(post.id, true)} onBookmark={() => handleBookmark(post)} onShare={() => handleShare(post)} onImagePress={(images, index) => handleImagePress(images, index)} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} /> ); })} ); }; // 渲染空状态 const renderEmpty = () => { if (loading) return null; return ( ); }; // 渲染列表内容 const renderListContent = () => { if (useMultiColumnList) { // 平板/桌面端使用多列网格 return renderResponsiveGrid(); } // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( item.id} contentContainerStyle={[ styles.listContent, { paddingHorizontal: listHorizontalPadding, paddingBottom: 80 + responsivePadding, alignItems: 'center', // 确保子项居中 } ]} showsVerticalScrollIndicator={false} refreshControl={ } onEndReached={onEndReached} onEndReachedThreshold={0.3} ListEmptyComponent={renderEmpty} ListFooterComponent={loadingMore ? : null} /> ); }; return ( {/* 顶部Header */} {/* 搜索栏 */} {}} onSubmit={handleSearchPress} onFocus={handleSearchPress} placeholder="搜索帖子、用户" /> {/* Tab切换 */} } /> {/* 帖子列表 */} {viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 renderResponsiveGrid() )} {/* 漂浮发帖按钮 */} {/* 图片查看器 */} ({ id: img.id || img.url || String(Math.random()), url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={() => setShowImageViewer(false)} enableSave /> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background.default, }, header: { backgroundColor: colors.background.paper, }, searchWrapper: { paddingBottom: spacing.sm, }, viewToggleBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', }, listContent: { flexGrow: 1, }, contentContainer: { flex: 1, }, listItem: { // 动态设置 marginBottom }, waterfallScroll: { flex: 1, }, waterfallContainer: { flexDirection: 'row', flexGrow: 1, alignItems: 'flex-start', }, waterfallColumn: { flex: 1, }, waterfallItem: { // 动态设置 marginBottom }, floatingButton: { position: 'absolute', right: 20, bottom: 20, width: 56, height: 56, borderRadius: 28, backgroundColor: colors.primary.main, alignItems: 'center', justifyContent: 'center', ...shadows.lg, }, floatingButtonDesktop: { right: 40, bottom: 40, width: 64, height: 64, borderRadius: 32, }, floatingButtonWide: { right: 60, bottom: 60, width: 72, height: 72, borderRadius: 36, }, });