/** * 首页 HomeScreen * 胡萝卜BBS - 首页展示 * 支持列表和多列网格模式(响应式布局) */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, FlatList, ScrollView, Platform, StyleSheet, RefreshControl, StatusBar, TouchableOpacity, NativeSyntheticEvent, Alert, Clipboard, Modal, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; 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 { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; import * as hrefs from '../../navigation/hrefs'; const TABS = ['关注', '最新', '热门']; const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire']; const DEFAULT_PAGE_SIZE = 20; 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'; type PostType = 'follow' | 'hot' | 'latest'; type LatestCapsule = { id: string; name: string }; export const HomeScreen: React.FC = () => { const router = useRouter(); const insets = useSafeAreaInsets(); const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 使用响应式 hook const { width, isMobile, isTablet, isDesktop, isWideScreen, } = 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(1); const [viewMode, setViewMode] = useState('list'); const [latestCapsules, setLatestCapsules] = useState([{ id: '', name: '全部' }]); const [activeCapsuleId, setActiveCapsuleId] = useState(''); // 图片查看器状态 const [showImageViewer, setShowImageViewer] = useState(false); const [postImages, setPostImages] = useState([]); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // 搜索显示状态(用于内嵌搜索页面) const [showSearch, setShowSearch] = useState(false); // 发帖弹窗状态 const [showCreatePost, setShowCreatePost] = useState(false); // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); const isLoadingMoreRef = useRef(false); // 构建一个以 postId 为 key 的 map,用于快速查找 const postsMap = useMemo(() => { const map = new Map(); storePosts.forEach(post => { map.set(post.id, post); }); return map; }, [storePosts]); // 获取当前 tab 对应的帖子类型 const getPostType = useCallback((): PostType => { switch (activeIndex) { case 0: return 'follow'; case 1: return 'latest'; case 2: return 'hot'; default: return 'latest'; } }, [activeIndex]); const isLatestTab = activeIndex === 1; const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined; // 使用差异更新 Hook 获取帖子列表 const listKey = useMemo( () => `home_${getPostType()}_${currentChannelId || 'all'}`, [getPostType, currentChannelId] ); const { posts, loading: isLoading, isInitialLoading, isLoadingMore, refreshing: isRefreshing, hasMore, error, reset, } = useDifferentialPosts( [], { listKey, enableDiff: true, enableBatching: true, autoSubscribe: true, } ); // 加载更多方法 const loadMore = useCallback(async () => { if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) { isLoadingMoreRef.current = true; const startedAt = Date.now(); try { await processPostUseCase.loadMorePosts(listKey); console.log('[PostPerf] home loadMore', { listKey, costMs: Date.now() - startedAt, totalItems: posts.length, }); } catch (err) { console.error('加载更多失败:', err); } finally { isLoadingMoreRef.current = false; } } }, [posts.length, listKey, hasMore, isLoadingMore]); // 网格模式滚动处理 - 检测是否滚动到底部 const handleGridScroll = useCallback((event: any) => { const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; const scrollY = contentOffset.y; const visibleHeight = layoutMeasurement.height; const contentHeight = contentSize.height; // 当滚动到距离底部 200px 时触发加载更多 if (scrollY + visibleHeight >= contentHeight - 200) { loadMore(); } }, [loadMore]); // 刷新方法 - 先获取正确类型的帖子,再刷新 const refresh = useCallback(async () => { try { // 先获取正确类型的帖子 await processPostUseCase.fetchPosts( { page: 1, pageSize: DEFAULT_PAGE_SIZE, post_type: getPostType(), channelId: currentChannelId, }, listKey ); } catch (err) { console.error('刷新失败:', err); } }, [listKey, getPostType, currentChannelId]); // Tab 切换时刷新数据并重置列表 useEffect(() => { reset(); refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex, currentChannelId]); // 将获取到的原始帖子与 store 中的状态合并 // 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等) const displayPosts = useMemo(() => { return posts.map(post => { const storePost = postsMap.get(post.id); if (storePost) { // 如果 store 中有这个帖子,使用 store 的最新状态 return storePost; } // 如果 store 中没有这个帖子,使用原始数据 return post; }); }, [posts, postsMap]); /** 按 id 取当前列表中的最新 post,配合 PostCard memo 忽略 onAction 引用 */ const postByIdRef = useRef>(new Map()); postByIdRef.current = new Map(displayPosts.map((p) => [p.id, p])); // 根据屏幕尺寸确定网格列数 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; } // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方 // TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16 return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom; }, [isMobile, insets.bottom]); // 切换视图模式 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]); useEffect(() => { const loadChannels = async () => { const list = await channelService.list(); const capsules: LatestCapsule[] = [ { id: '', name: '全部' }, ...list.map(item => ({ id: item.id, name: item.name })), ]; setLatestCapsules(capsules); }; loadChannels(); }, []); 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]) .shouldCancelWhenOutside(true) .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 = () => { setShowSearch(true); }; // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { router.push(hrefs.hrefUserProfile(userId)); }; // 点赞帖子 const handleLike = async (post: Post) => { try { if (post.is_liked) { await processPostUseCase.unlikePost(post.id); } else { await processPostUseCase.likePost(post.id); } } catch (error) { console.error('点赞操作失败:', error); } }; // 收藏帖子 const handleBookmark = async (post: Post) => { try { if (post.is_favorited) { await processPostUseCase.unfavoritePost(post.id); } else { await processPostUseCase.favoritePost(post.id); } } catch (error) { console.error('收藏操作失败:', error); } }; // 分享帖子 const handleShare = async (post: Post) => { if (!post?.id) return; try { const res = await postService.sharePost(post.id, { channel: 'copy_link' }); if (res.ok && res.shares_count != null) { processPostUseCase.applyShareCountUpdate(post.id, res.shares_count); } } catch (shareError) { console.error('上报分享次数失败:', shareError); } const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; Clipboard.setString(postUrl); Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; // 删除帖子 - 删除后刷新列表 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); if (success) { // 刷新列表以移除已删除的帖子 refresh(); } else { console.error('删除帖子失败'); } } catch (deleteError) { console.error('删除帖子失败:', deleteError); throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示 } }; // 处理图片点击 - 打开图片查看器 const handleImagePress = (images: ImageGridItem[], index: number) => { setPostImages(images); setSelectedImageIndex(index); setShowImageViewer(true); }; // 统一处理 PostCard 的所有操作 const handlePostAction = (post: Post, action: PostCardAction) => { switch (action.type) { case 'press': handlePostPress(post.id); break; case 'userPress': const authorId = post.author?.id; if (authorId) handleUserPress(authorId); break; case 'like': case 'unlike': handleLike(post); break; case 'comment': handlePostPress(post.id, true); break; case 'bookmark': case 'unbookmark': handleBookmark(post); break; case 'share': handleShare(post); break; case 'imagePress': if (action.payload?.images && action.payload?.imageIndex !== undefined) { handleImagePress(action.payload.images, action.payload.imageIndex); } break; case 'delete': handleDeletePost(post.id); break; } }; const handlePostActionRef = useRef(handlePostAction); handlePostActionRef.current = handlePostAction; const stableOnPostAction = useCallback((postId: string, action: PostCardAction) => { const post = postByIdRef.current.get(postId); if (post) { handlePostActionRef.current(post, action); } }, []); // 跳转到发帖页面(使用 Modal 方式) const handleCreatePost = () => { setShowCreatePost(true); }; const renderLatestCapsules = () => { if (!isLatestTab) { return null; } return ( {latestCapsules.map((item) => { const isActive = item.id === activeCapsuleId; return ( setActiveCapsuleId(item.id)} style={styles.capsuleItem} > {item.name} ); })} ); }; // 渲染帖子卡片(列表模式) const keyExtractor = useCallback((item: Post) => item.id, []); const renderPostList = useCallback(({ item }: { item: Post }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( stableOnPostAction(item.id, action)} isPostAuthor={isPostAuthor} /> ); }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) 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); // 防御性检查:确保 posts 存在且是数组 if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) { return columns; } // 计算单列宽度 const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; displayPosts.forEach((post) => { const postHeight = estimatePostHeight(post, columnWidth); // 找到当前高度最小的列 const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights)); columns[minHeightIndex].push(post); columnHeights[minHeightIndex] += postHeight; }); return columns; }, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]); // 渲染单列帖子 const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( {column.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> ); })} ); // 渲染响应式网格布局(平板/桌面端使用多列) const renderResponsiveGrid = () => { if (isMobile && !isTablet) { // 移动端使用瀑布流布局(2列) return ( } > {renderLatestCapsules()} {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} {/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} {isLoadingMore && displayPosts.length > 0 && ( )} ); } // 平板/桌面端使用 ResponsiveGrid 组件 return ( {displayPosts.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> ); })} ); }; // 渲染空状态 const renderEmpty = useCallback(() => { if (isInitialLoading) return null; return ( ); }, [activeIndex, isInitialLoading]); // 渲染列表内容 const renderListContent = () => { if (useMultiColumnList) { // 平板/桌面端使用多列网格 return renderResponsiveGrid(); } // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( } onEndReached={loadMore} onEndReachedThreshold={0.3} scrollEventThrottle={16} ListEmptyComponent={renderEmpty} ListFooterComponent={isLoadingMore ? : null} initialNumToRender={8} maxToRenderPerBatch={8} updateCellsBatchingPeriod={60} windowSize={7} removeClippedSubviews={false} /> ); }; // 搜索页面内嵌模式 if (showSearch) { return ( setShowSearch(false)} /> ); } // 正常首页内容 return ( {/* 顶部Header */} {/* 搜索栏 */} {}} onSubmit={handleSearchPress} onFocus={handleSearchPress} placeholder="搜索帖子、用户" /> {/* Tab切换 */} } /> {/* 帖子列表 */} {isMobile ? ( // 移动端:使用 GestureDetector 支持手势切换 Tab {isInitialLoading ? ( ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 renderResponsiveGrid() )} ) : ( // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击 {isInitialLoading ? ( ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 renderResponsiveGrid() )} )} {/* 漂浮发帖按钮 */} {/* 图片查看器 */} ({ id: img.id || img.url || String(Math.random()), url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={() => setShowImageViewer(false)} enableSave /> {/* 发帖弹窗 */} setShowCreatePost(false)} > setShowCreatePost(false)} /> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background.default, }, header: { backgroundColor: colors.background.paper, }, searchWrapper: { paddingBottom: spacing.sm, // 移除阴影效果 shadowColor: 'transparent', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0, shadowRadius: 0, elevation: 0, }, viewToggleBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', }, capsuleWrapper: { paddingBottom: spacing.sm, backgroundColor: colors.background.paper, }, capsuleList: { gap: spacing.xs, paddingRight: spacing.lg, }, capsuleItem: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: 999, }, capsuleText: { fontSize: 13, fontWeight: '600', color: colors.text.secondary, }, capsuleTextActive: { color: colors.primary.main, }, listContent: { flexGrow: 1, }, listHeader: { width: '100%', }, contentContainer: { flex: 1, }, listItem: { // 动态设置 marginBottom }, waterfallScroll: { flex: 1, }, waterfallContainer: { width: '100%', flexGrow: 1, }, waterfallColumnsRow: { width: '100%', flexDirection: 'row', 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, }, loadingMoreFooter: { paddingVertical: 20, alignItems: 'center', justifyContent: 'center', }, });