Initial frontend repository commit.

Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
This commit is contained in:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View File

@@ -0,0 +1,767 @@
/**
* 首页 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<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);
}
console.log('[HomeScreen] loadPosts - activeIndex:', activeIndex, 'postType:', postType);
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 = (post: Post) => {
console.log('Share post:', post.id);
};
// 删除帖子
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,
},
});