Files
frontend/src/screens/home/HomeScreen.tsx

1124 lines
35 KiB
TypeScript
Raw Normal View History

/**
* HomeScreen
* -
*
*/
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
ScrollView,
Platform,
StyleSheet,
RefreshControl,
StatusBar,
TouchableOpacity,
NativeSyntheticEvent,
NativeScrollEvent,
Alert,
Clipboard,
2026-03-18 00:25:46 +08:00
Modal,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PagerView } from '../../components/common';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar, ShareSheet } 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';
2026-04-13 01:30:37 +08:00
import { postSyncService } from '@/services/post';
2026-03-16 17:47:10 +08:00
import { SearchScreen } from './SearchScreen';
2026-03-18 00:25:46 +08:00
import { CreatePostScreen } from '../create/CreatePostScreen';
import * as hrefs from '../../navigation/hrefs';
import { blurActiveElement } from '../../infrastructure/platform';
const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
const DEFAULT_PAGE_SIZE = 20;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12;
/** 首页纵向滑动超过此阈值视为明确向下/向上划,用于隐藏或显示底部 Tab */
const TAB_BAR_SCROLL_DELTA_Y = 10;
/** 停止滑动多久后自动恢复底部 Tab */
const TAB_BAR_IDLE_RESTORE_MS = 2200;
type ViewMode = 'list' | 'grid';
type PostType = 'follow' | 'hot' | 'latest';
type LatestCapsule = { id: string; name: string };
function createHomeStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingTop: spacing.lg,
paddingBottom: spacing.xs,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
homeTabBar: {
marginTop: spacing.sm,
marginBottom: spacing.md,
},
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: {},
waterfallScroll: {
flex: 1,
},
waterfallContainer: {
width: '100%',
flexGrow: 1,
},
waterfallColumnsRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
},
waterfallColumn: {
flex: 1,
},
waterfallItem: {},
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',
},
});
}
export const HomeScreen: React.FC = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore();
const currentUser = useCurrentUser();
const isAuthenticated = useIsAuthenticated();
const isVerified = useIsVerified();
const fetchVerificationStatus = useVerificationStore((s) => s.fetchStatus);
const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus);
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const styles = useMemo(() => createHomeStyles(colors), [colors]);
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
// 使用响应式 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<ViewMode>('list');
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
const [activeCapsuleId, setActiveCapsuleId] = useState('');
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
2026-03-16 17:47:10 +08:00
// 搜索显示状态(用于内嵌搜索页面)
const [showSearch, setShowSearch] = useState(false);
2026-03-18 00:25:46 +08:00
// 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false);
// 分享面板状态
const [showShareSheet, setShowShareSheet] = useState(false);
const [sharePost, setSharePost] = useState<Post | null>(null);
useEffect(() => {
if (isAuthenticated && !hasFetchedVerification) {
fetchVerificationStatus();
}
}, [isAuthenticated, hasFetchedVerification, fetchVerificationStatus]);
useEffect(() => {
if (showCreatePost) {
blurActiveElement();
}
}, [showCreatePost]);
2026-03-18 00:25:46 +08:00
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = useRef<Set<string>>(new Set());
const isLoadingMoreRef = useRef(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pagerRef = useRef<any>(null);
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FlatList 和 ScrollView 的 ref用于滚动到顶部
const flatListRef = useRef<FlatList<Post> | null>(null);
const scrollViewRef = useRef<ScrollView | null>(null);
const clearTabBarIdleRestoreTimer = useCallback(() => {
if (tabBarIdleRestoreTimerRef.current != null) {
clearTimeout(tabBarIdleRestoreTimerRef.current);
tabBarIdleRestoreTimerRef.current = null;
}
}, []);
const scheduleTabBarIdleRestore = useCallback(() => {
clearTabBarIdleRestoreTimer();
tabBarIdleRestoreTimerRef.current = setTimeout(() => {
setBottomTabBarHiddenByScroll(false);
tabBarIdleRestoreTimerRef.current = null;
}, TAB_BAR_IDLE_RESTORE_MS);
}, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
const handleHomeVerticalScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
const dy = y - homeListScrollYRef.current;
homeListScrollYRef.current = y;
scheduleTabBarIdleRestore();
if (y <= 0) {
setBottomTabBarHiddenByScroll(false);
return;
}
if (dy > TAB_BAR_SCROLL_DELTA_Y) {
setBottomTabBarHiddenByScroll(true);
} else if (dy < -TAB_BAR_SCROLL_DELTA_Y) {
setBottomTabBarHiddenByScroll(false);
}
},
[scheduleTabBarIdleRestore, setBottomTabBarHiddenByScroll]
);
useFocusEffect(
useCallback(() => {
return () => {
clearTabBarIdleRestoreTimer();
homeListScrollYRef.current = 0;
setBottomTabBarHiddenByScroll(false);
};
}, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll])
);
useEffect(() => {
if (showSearch) {
clearTabBarIdleRestoreTimer();
setBottomTabBarHiddenByScroll(false);
}
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
// 监听首页 Tab 点击事件,滚动到顶部
useEffect(() => {
if (homeTabPressCount > 0) {
// 滚动 FlatList 到顶部
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
// 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态
setBottomTabBarHiddenByScroll(false);
homeListScrollYRef.current = 0;
}
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
const capsuleHScrollRef = useRef<ScrollView | null>(null);
const capsuleScrollXRef = useRef(0);
const restoreCapsuleStripScroll = useCallback(() => {
const x = capsuleScrollXRef.current;
if (x <= 0) return;
const scroll = () => capsuleHScrollRef.current?.scrollTo({ x, animated: false });
scroll();
requestAnimationFrame(() => {
requestAnimationFrame(scroll);
});
}, []);
const onCapsuleHorizontalScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
capsuleScrollXRef.current = e.nativeEvent.contentOffset.x;
}, []);
// 构建一个以 postId 为 key 的 map用于快速查找
const postsMap = useMemo(() => {
const map = new Map<string, Post>();
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;
useEffect(() => {
homeListScrollYRef.current = 0;
setBottomTabBarHiddenByScroll(false);
}, [activeIndex, currentChannelId, viewMode, setBottomTabBarHiddenByScroll]);
useLayoutEffect(() => {
if (!isLatestTab) return;
restoreCapsuleStripScroll();
}, [isLatestTab, activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
// 使用差异更新 Hook 获取帖子列表
const listKey = useMemo(
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
[getPostType, currentChannelId]
);
const {
posts,
loading: isLoading,
isInitialLoading,
isLoadingMore,
refreshing: isRefreshing,
hasMore,
error,
reset,
} = useDifferentialPosts<Post>(
[],
{
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 postSyncService.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]);
// 网格模式滚动处理 - 检测是否滚动到底部 + 底部 Tab 显隐
const handleGridScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
handleHomeVerticalScroll(event);
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const scrollY = contentOffset.y;
const visibleHeight = layoutMeasurement.height;
const contentHeight = contentSize.height;
if (scrollY + visibleHeight >= contentHeight - 200) {
loadMore();
}
},
[handleHomeVerticalScroll, loadMore]
);
// 刷新方法 - 先获取正确类型的帖子,再刷新
const refresh = useCallback(async () => {
try {
// 先获取正确类型的帖子
await postSyncService.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 同步点赞等状态channel 等列表专用字段在 store 里常缺失,从列表帖合并
return {
...post,
...storePost,
channel: storePost.channel ?? post.channel,
};
}
return post;
});
}, [posts, postsMap]);
/** 按 id 取当前列表中的最新 post配合 PostCard memo 忽略 onAction 引用 */
const postByIdRef = useRef<Map<string, Post>>(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 1200;
if (isDesktop) return 1000;
if (isTablet) return 800;
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;
}
if (bottomTabBarHiddenByScroll) {
return MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP + insets.bottom;
}
// TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方
return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom;
}, [isMobile, insets.bottom, bottomTabBarHiddenByScroll]);
// 切换视图模式
const toggleViewMode = () => {
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
};
// 切换Tab点击 TabBar 或手势滑动)
const changeTab = useCallback((nextIndex: number) => {
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
return;
}
setActiveIndex(nextIndex);
if (Platform.OS !== 'web') {
pagerRef.current?.setPage(nextIndex);
}
}, [activeIndex]);
const onPagerPageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
const nextIndex = e.nativeEvent.position;
if (nextIndex !== activeIndex) {
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();
}, []);
2026-03-16 17:47:10 +08:00
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => {
2026-03-16 17:47:10 +08:00
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 postSyncService.unlikePost(post.id);
} else {
await postSyncService.likePost(post.id);
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
// 收藏帖子
const handleBookmark = async (post: Post) => {
try {
if (post.is_favorited) {
await postSyncService.unfavoritePost(post.id);
} else {
await postSyncService.favoritePost(post.id);
}
} catch (error) {
console.error('收藏操作失败:', error);
}
};
// 分享帖子 - 打开分享面板
const handleShare = (post: Post) => {
setSharePost(post);
setShowShareSheet(true);
};
// 删除帖子 - 删除后刷新列表
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);
}
}, []);
2026-03-18 00:25:46 +08:00
// 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => {
if (!isAuthenticated) {
router.push('/login');
return;
}
if (!isVerified) {
router.push(hrefs.hrefVerificationGuide());
return;
}
2026-03-18 00:25:46 +08:00
setShowCreatePost(true);
};
const renderLatestCapsules = () => {
if (!isLatestTab) {
return null;
}
return (
<View style={[styles.capsuleWrapper, { paddingHorizontal: responsivePadding }]}>
<ScrollView
ref={capsuleHScrollRef}
horizontal
showsHorizontalScrollIndicator={false}
nestedScrollEnabled
contentContainerStyle={styles.capsuleList}
onScroll={onCapsuleHorizontalScroll}
scrollEventThrottle={16}
onContentSizeChange={restoreCapsuleStripScroll}
>
{latestCapsules.map((item) => {
const isActive = item.id === activeCapsuleId;
return (
<TouchableOpacity
key={item.id || 'all'}
activeOpacity={0.85}
onPress={() => setActiveCapsuleId(item.id)}
style={styles.capsuleItem}
>
<Text
style={
isActive
? { ...styles.capsuleText, ...styles.capsuleTextActive }
: styles.capsuleText
}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
};
// 渲染帖子卡片(列表模式)
const keyExtractor = useCallback((item: Post) => item.id, []);
const renderPostList = useCallback(({ item }: { item: Post }) => {
const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View style={[
styles.listItem,
{
marginBottom: responsiveGap,
width: listItemWidth,
alignSelf: 'center',
borderRadius: isMobile ? borderRadius.lg : 0,
overflow: isMobile ? 'hidden' : 'visible',
}
]}>
<PostCard
post={item}
onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [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) => (
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
{column.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
<PostCard
post={post}
variant="grid"
onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
// 渲染响应式网格布局(平板/桌面端使用多列)
const renderResponsiveGrid = () => {
if (isMobile && !isTablet) {
// 移动端使用瀑布流布局2列
return (
<ScrollView
ref={scrollViewRef}
style={styles.waterfallScroll}
contentContainerStyle={[
styles.waterfallContainer,
{
paddingHorizontal: responsivePadding,
paddingBottom: 80 + responsivePadding,
}
]}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={handleGridScroll}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
{renderLatestCapsules()}
<View style={styles.waterfallColumnsRow}>
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
</View>
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */}
{isLoadingMore && displayPosts.length > 0 && (
<View style={styles.loadingMoreFooter}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 平板/桌面端使用 ResponsiveGrid 组件
return (
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
>
{displayPosts.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<PostCard
key={post.id}
post={post}
variant={viewMode === 'grid' ? 'grid' : 'list'}
onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor}
/>
);
})}
</ResponsiveGrid>
);
};
// 渲染空状态
const renderEmpty = useCallback(() => {
if (isInitialLoading) return null;
return (
<EmptyState
title="暂无内容"
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
/>
);
}, [activeIndex, isInitialLoading]);
// 渲染列表内容
const renderListContent = () => {
if (useMultiColumnList) {
// 平板/桌面端使用多列网格
return renderResponsiveGrid();
}
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return (
<FlatList
ref={flatListRef}
data={displayPosts}
renderItem={renderPostList}
keyExtractor={keyExtractor}
ListHeaderComponent={renderLatestCapsules()}
ListHeaderComponentStyle={styles.listHeader}
contentContainerStyle={[
styles.listContent,
{
paddingHorizontal: listHorizontalPadding,
paddingBottom: 80 + responsivePadding,
alignItems: 'center', // 确保子项居中
}
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
onScroll={handleHomeVerticalScroll}
scrollEventThrottle={16}
ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
initialNumToRender={8}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews={false}
/>
);
};
2026-03-16 17:47:10 +08:00
// 搜索页面内嵌模式
if (showSearch) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
2026-03-16 17:47:10 +08:00
<SearchScreen
onBack={() => setShowSearch(false)}
/>
</SafeAreaView>
);
}
// 正常首页内容
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
{/* 顶部Header */}
<View style={styles.header}>
{/* 搜索栏 */}
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={handleSearchPress}
onFocus={handleSearchPress}
placeholder="搜索帖子、用户"
/>
</View>
{/* Tab切换 */}
<TabBar
tabs={TABS}
icons={TAB_ICONS}
activeIndex={activeIndex}
onTabChange={changeTab}
variant="modern"
style={styles.homeTabBar}
rightContent={
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
<MaterialCommunityIcons
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
size={22}
color="#666"
/>
</TouchableOpacity>
}
/>
</View>
{/* 帖子列表 */}
{Platform.OS === 'web' ? (
<View style={styles.contentContainer}>
{isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
renderListContent()
) : (
renderResponsiveGrid()
)}
</View>
) : (
<PagerView
ref={pagerRef}
style={styles.contentContainer}
initialPage={activeIndex}
onPageSelected={onPagerPageSelected}
overdrag
>
<View key="follow" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 0 ? (
<Loading fullScreen />
) : activeIndex === 0 ? (
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : (
<View style={styles.contentContainer} />
)}
</View>
<View key="latest" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 1 ? (
<Loading fullScreen />
) : activeIndex === 1 ? (
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : (
<View style={styles.contentContainer} />
)}
</View>
<View key="hot" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 2 ? (
<Loading fullScreen />
) : activeIndex === 2 ? (
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : (
<View style={styles.contentContainer} />
)}
</View>
</PagerView>
2026-03-16 17:47:10 +08:00
)}
{/* 漂浮发帖按钮 */}
<TouchableOpacity
style={[
styles.floatingButton,
isDesktop && styles.floatingButtonDesktop,
isWideScreen && styles.floatingButtonWide,
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]}
onPress={handleCreatePost}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
</TouchableOpacity>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
2026-03-16 17:47:10 +08:00
images={postImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)}
enableSave
/>
2026-03-18 00:25:46 +08:00
{/* 发帖弹窗 */}
<Modal
visible={showCreatePost}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowCreatePost(false)}
>
<CreatePostScreen
onClose={() => setShowCreatePost(false)}
/>
</Modal>
{/* 分享面板 */}
<ShareSheet
visible={showShareSheet}
postUrl={sharePost ? `https://browser.littlelan.cn/post/${encodeURIComponent(sharePost.id)}` : undefined}
postTitle={sharePost?.title}
onClose={() => {
setShowShareSheet(false);
setSharePost(null);
}}
onShareAction={(actionKey) => {
if (sharePost) {
(async () => {
try {
const res = await postService.sharePost(sharePost.id, { channel: actionKey });
if (res.ok && res.shares_count != null) {
postSyncService.applyShareCountUpdate(sharePost.id, res.shares_count);
}
} catch (e) {
console.error('上报分享次数失败:', e);
}
})();
}
}}
/>
</SafeAreaView>
);
};