feat(pagination): implement cursor-based pagination across the app
Add useCursorPagination hook and update multiple screens and services to use cursor-based pagination for better performance and consistency. - Add useCursorPagination hook with deduplication and caching support - Add cursor pagination types to infrastructure layer - Refactor HomeScreen, PostDetailScreen, SearchScreen for posts/comments - Refactor GroupMembersScreen, JoinGroupScreen for groups/members - Refactor MessageListScreen, NotificationsScreen for messages - Update post, message, group, comment, notification services with cursor endpoints - Add CursorPaginationRequest/Response DTOs - Remove deprecated OPTIMIZATION_DESIGN.md documentation
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
TouchableOpacity,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Alert,
|
||||
Clipboard,
|
||||
@@ -33,6 +32,8 @@ 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';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { CursorPaginationRequest } from '../../types/dto';
|
||||
import { SearchScreen } from './SearchScreen';
|
||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
@@ -42,8 +43,6 @@ type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & Na
|
||||
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;
|
||||
@@ -51,11 +50,12 @@ const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
const MOBILE_FAB_GAP = 12;
|
||||
|
||||
type ViewMode = 'list' | 'grid';
|
||||
type PostType = 'recommend' | 'follow' | 'hot' | 'latest';
|
||||
|
||||
export const HomeScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||
const { likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
// 使用响应式 hook
|
||||
@@ -65,9 +65,6 @@ export const HomeScreen: React.FC = () => {
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWideScreen,
|
||||
breakpoint,
|
||||
orientation,
|
||||
isLandscape
|
||||
} = useResponsive();
|
||||
|
||||
// 响应式间距
|
||||
@@ -75,12 +72,6 @@ export const HomeScreen: React.FC = () => {
|
||||
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');
|
||||
|
||||
// 图片查看器状态
|
||||
@@ -95,18 +86,56 @@ export const HomeScreen: React.FC = () => {
|
||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||
|
||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||
const postIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
|
||||
const lastLoadMoreTriggerAtRef = useRef(0);
|
||||
const postIdsRef = useRef<Set<string>>(new Set());
|
||||
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;
|
||||
// 获取当前 tab 对应的帖子类型
|
||||
const getPostType = useCallback((): PostType => {
|
||||
switch (activeIndex) {
|
||||
case 0: return 'recommend';
|
||||
case 1: return 'follow';
|
||||
case 2: return 'hot';
|
||||
case 3: return 'latest';
|
||||
default: return 'recommend';
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// 使用游标分页获取帖子列表
|
||||
const {
|
||||
items: posts,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
hasMore,
|
||||
error,
|
||||
loadMore,
|
||||
refresh,
|
||||
} = useCursorPagination<Post, { post_type: PostType }>(
|
||||
async ({ cursor, pageSize, extraParams }) => {
|
||||
const params: CursorPaginationRequest = {
|
||||
cursor,
|
||||
page_size: pageSize,
|
||||
post_type: extraParams?.post_type,
|
||||
};
|
||||
const response = await postService.getPostsCursor(params);
|
||||
return response;
|
||||
},
|
||||
{ pageSize: DEFAULT_PAGE_SIZE },
|
||||
{ post_type: getPostType() }
|
||||
);
|
||||
|
||||
// Tab 切换时刷新数据
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [activeIndex]);
|
||||
|
||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
||||
useEffect(() => {
|
||||
if (posts.length === 0) return;
|
||||
|
||||
// 更新 postIdsRef
|
||||
const currentPostIds = new Set(posts.map(p => p.id));
|
||||
postIdsRef.current = currentPostIds;
|
||||
}, [posts, storePosts]);
|
||||
|
||||
// 根据屏幕尺寸确定网格列数
|
||||
const gridColumns = useMemo(() => {
|
||||
@@ -154,147 +183,6 @@ export const HomeScreen: React.FC = () => {
|
||||
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT;
|
||||
}, [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');
|
||||
@@ -375,27 +263,27 @@ export const HomeScreen: React.FC = () => {
|
||||
if (!post?.id) return;
|
||||
try {
|
||||
await postService.sharePost(post.id);
|
||||
} catch (error) {
|
||||
console.error('上报分享次数失败:', error);
|
||||
} catch (shareError) {
|
||||
console.error('上报分享次数失败:', shareError);
|
||||
}
|
||||
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
||||
Clipboard.setString(postUrl);
|
||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
||||
};
|
||||
|
||||
// 删除帖子
|
||||
// 删除帖子 - 由于 posts 来自 Hook,需要刷新列表
|
||||
const handleDeletePost = async (postId: string) => {
|
||||
try {
|
||||
const success = await postService.deletePost(postId);
|
||||
if (success) {
|
||||
// 从列表中移除已删除的帖子
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
// 刷新列表以移除已删除的帖子
|
||||
refresh();
|
||||
} else {
|
||||
console.error('删除帖子失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error; // 重新抛出错误,让 PostCard 处理错误提示
|
||||
} catch (deleteError) {
|
||||
console.error('删除帖子失败:', deleteError);
|
||||
throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示
|
||||
}
|
||||
};
|
||||
|
||||
@@ -545,12 +433,11 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={onWaterfallScroll}
|
||||
scrollEventThrottle={100}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
@@ -594,7 +481,7 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading) return null;
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -628,16 +515,16 @@ export const HomeScreen: React.FC = () => {
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={onEndReached}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={loadingMore ? <Loading size="sm" /> : null}
|
||||
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user