Files
frontend/src/screens/home/HomeScreen.tsx
lan be84c01abd Migrate frontend realtime messaging to SSE.
Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts.

Made-with: Cursor
2026-03-10 12:58:23 +08:00

777 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 首页 HomeScreen
* 胡萝卜BBS - 首页展示
* 支持列表和多列网格模式(响应式布局)
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
ScrollView,
StyleSheet,
RefreshControl,
StatusBar,
TouchableOpacity,
NativeScrollEvent,
NativeSyntheticEvent,
Alert,
Clipboard,
} 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<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
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<NavigationProp>();
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<Post[]>([]);
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<ViewMode>('list');
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = React.useRef<Set<string>>(new Set());
const inFlightRequestKeysRef = React.useRef<Set<string>>(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<string, Post>();
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<NativeScrollEvent>) => {
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 = async (post: Post) => {
if (!post?.id) return;
try {
await postService.sharePost(post.id);
} catch (error) {
console.error('上报分享次数失败:', error);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
};
// 删除帖子
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 (
<View style={[
styles.listItem,
{
marginBottom: responsiveGap,
width: listItemWidth,
alignSelf: 'center',
borderRadius: isMobile ? borderRadius.lg : 0,
overflow: isMobile ? 'hidden' : 'visible',
}
]}>
<PostCard
post={item}
onPress={() => 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}
/>
</View>
);
};
// 估算帖子在瀑布流中的高度(用于均匀分配)
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) => (
<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"
onPress={() => 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}
/>
</View>
);
})}
</View>
);
// 渲染响应式网格布局(平板/桌面端使用多列)
const renderResponsiveGrid = () => {
if (isMobile && !isTablet) {
// 移动端使用瀑布流布局2列
return (
<ScrollView
style={styles.waterfallScroll}
contentContainerStyle={[
styles.waterfallContainer,
{
paddingHorizontal: responsivePadding,
paddingBottom: 80 + responsivePadding,
}
]}
showsVerticalScrollIndicator={false}
onScroll={onWaterfallScroll}
scrollEventThrottle={100}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
</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 }}
>
{posts.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<PostCard
key={post.id}
post={post}
variant={viewMode === 'grid' ? 'grid' : 'default'}
onPress={() => 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}
/>
);
})}
</ResponsiveGrid>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading) return null;
return (
<EmptyState
title="暂无内容"
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
/>
);
};
// 渲染列表内容
const renderListContent = () => {
if (useMultiColumnList) {
// 平板/桌面端使用多列网格
return renderResponsiveGrid();
}
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return (
<FlatList
data={posts}
renderItem={renderPostList}
keyExtractor={item => item.id}
contentContainerStyle={[
styles.listContent,
{
paddingHorizontal: listHorizontalPadding,
paddingBottom: 80 + responsivePadding,
alignItems: 'center', // 确保子项居中
}
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmpty}
ListFooterComponent={loadingMore ? <Loading size="sm" /> : null}
/>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="dark-content" 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"
rightContent={
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
<MaterialCommunityIcons
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
size={22}
color="#666"
/>
</TouchableOpacity>
}
/>
</View>
{/* 帖子列表 */}
<GestureDetector gesture={swipeGesture}>
<View style={styles.contentContainer}>
{viewMode === 'list' ? (
renderListContent()
) : (
// 网格模式:使用响应式网格
renderResponsiveGrid()
)}
</View>
</GestureDetector>
{/* 漂浮发帖按钮 */}
<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}
images={postImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)}
enableSave
/>
</SafeAreaView>
);
};
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,
},
});