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:
lafay
2026-03-20 23:00:27 +08:00
parent a005fb0a15
commit 8a0aea1c59
20 changed files with 2464 additions and 2166 deletions

View File

@@ -4,13 +4,14 @@
* 支持响应式布局,宽屏下显示更大的搜索结果区域
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -21,13 +22,15 @@ import { Post, User } from '../../types';
import { useUserStore } from '../../stores';
import { postService, authService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import { Avatar, EmptyState, Text, ResponsiveGrid } from '../../components/common';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
import { HomeStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
const TABS = ['帖子', '用户'];
const DEFAULT_PAGE_SIZE = 20;
type SearchType = 'posts' | 'users';
@@ -74,17 +77,47 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
const [searchText, setSearchText] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [searchResults, setSearchResults] = useState<{
posts: Post[];
users: User[];
}>({
posts: [],
users: [],
});
const [hasSearched, setHasSearched] = useState(false);
// 保存当前搜索关键词用于Tab切换时重新搜索
const [currentKeyword, setCurrentKeyword] = useState('');
// 使用游标分页进行帖子搜索
const {
items: searchResults,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
reset,
} = useCursorPagination<Post, { query: string }>(
async ({ cursor, pageSize, extraParams }) => {
if (!extraParams?.query) {
return { items: [], next_cursor: null, prev_cursor: null, has_more: false };
}
const response = await postService.searchPostsCursor(extraParams.query, {
cursor,
page_size: pageSize,
});
return response;
},
{ pageSize: DEFAULT_PAGE_SIZE },
{ query: '' }
);
// 用户搜索结果(保持原有分页方式)
const [userResults, setUserResults] = useState<User[]>([]);
const [userLoading, setUserLoading] = useState(false);
// 当搜索词变化时重置
useEffect(() => {
if (currentKeyword) {
refresh();
} else {
reset();
}
}, [currentKeyword]);
// 执行搜索 - 根据当前Tab执行对应类型的搜索
const performSearch = useCallback(async (keyword: string) => {
if (!keyword.trim()) return;
@@ -101,26 +134,20 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
const searchType = getSearchType();
if (searchType === 'posts') {
// 搜索帖子
const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20);
setSearchResults(prev => ({
...prev,
posts: postsResponse.list
}));
// 帖子搜索由 useCursorPagination 处理,这里只需触发刷新
refresh();
} else if (searchType === 'users') {
// 搜索用户
// 用户搜索保持原有方式
setUserLoading(true);
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
setSearchResults(prev => ({
...prev,
users: usersResponse.list
}));
setUserResults(usersResponse.list || []);
}
} catch (error) {
console.error('搜索失败:', error);
}
setHasSearched(true);
}, [addSearchHistory, activeIndex]);
}, [addSearchHistory, activeIndex, refresh]);
// 处理搜索提交
const handleSearch = () => {
@@ -159,9 +186,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
// 渲染帖子搜索结果(使用响应式网格)
const renderPostResults = () => {
const posts = searchResults.posts;
const posts = searchResults;
if (posts.length === 0) {
if (posts.length === 0 && !isLoading) {
return (
<EmptyState
title="未找到相关帖子"
@@ -177,6 +204,14 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: responsivePadding }}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
@@ -197,6 +232,11 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
/>
))}
</ResponsiveGrid>
{isLoading && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
@@ -220,15 +260,26 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
keyExtractor={item => item.id}
contentContainerStyle={{ paddingBottom: responsivePadding }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
/>
);
};
// 渲染用户搜索结果
const renderUserResults = () => {
const users = searchResults.users;
const users = userResults;
if (users.length === 0) {
if (users.length === 0 && !userLoading) {
return (
<EmptyState
title="未找到相关用户"
@@ -290,6 +341,11 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
</TouchableOpacity>
))}
</ResponsiveGrid>
{userLoading && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
@@ -329,6 +385,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
keyExtractor={item => item.id}
contentContainerStyle={{ paddingVertical: responsiveGap }}
showsVerticalScrollIndicator={false}
ListFooterComponent={userLoading ? <Loading size="sm" /> : null}
/>
);
};
@@ -501,20 +558,20 @@ const styles = StyleSheet.create({
},
tabWrapper: {
backgroundColor: colors.background.paper,
paddingTop: spacing.xs,
paddingBottom: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
},
suggestionsContainer: {
flex: 1,
},
section: {
marginTop: spacing.lg,
marginTop: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontWeight: '600',
@@ -528,17 +585,32 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.divider,
},
tagText: {
marginLeft: spacing.xs,
},
// 移动端用户列表样式
userCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
},
userCardInfo: {
flex: 1,
marginLeft: spacing.md,
},
userCardName: {
fontWeight: '600',
color: colors.text.primary,
},
userItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
borderRadius: borderRadius.lg,
},
userInfo: {
flex: 1,
@@ -549,28 +621,15 @@ const styles = StyleSheet.create({
color: colors.text.primary,
},
followingBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary.light + '30',
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: `${colors.primary.main}14`,
alignItems: 'center',
justifyContent: 'center',
},
// 桌面端用户卡片样式
userCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
loadingMore: {
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
minHeight: 180,
},
userCardInfo: {
alignItems: 'center',
marginTop: spacing.md,
},
userCardName: {
fontWeight: '600',
color: colors.text.primary,
marginBottom: spacing.xs,
},
});