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

@@ -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}
/>
);
};

View File

@@ -32,6 +32,7 @@ import { Post, Comment, VoteResultDTO } from '../../types';
import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
import { RootStackParamList } from '../../navigation/types';
@@ -76,6 +77,30 @@ export const PostDetailScreen: React.FC = () => {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 使用游标分页 Hook 管理评论列表
const {
items: paginatedComments,
isLoading: isCommentsLoading,
isRefreshing: isCommentsRefreshing,
hasMore: hasMoreComments,
loadMore: loadMoreComments,
refresh: refreshComments,
error: commentsError,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await commentService.getPostCommentsCursor(postId, {
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
// 同步分页评论到本地状态
useEffect(() => {
setComments(paginatedComments);
}, [paginatedComments]);
const [commentText, setCommentText] = useState('');
const [showImageModal, setShowImageModal] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
@@ -165,9 +190,8 @@ export const PostDetailScreen: React.FC = () => {
}
}
// 加载评论
const commentsData = await commentService.getPostComments(postId);
setComments(commentsData.list);
// 加载评论(使用游标分页刷新)
await refreshComments();
} catch (error) {
console.error('加载帖子详情失败:', error);
} finally {
@@ -261,12 +285,13 @@ export const PostDetailScreen: React.FC = () => {
};
}, []);
// 下拉刷新
const onRefresh = useCallback(() => {
// 下拉刷新 - 同时刷新帖子和评论
const onRefresh = useCallback(async () => {
setRefreshing(true);
loadPostDetail();
await loadPostDetail();
await refreshComments();
setRefreshing(false);
}, [loadPostDetail]);
}, [loadPostDetail, refreshComments]);
const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return '';
@@ -1375,12 +1400,34 @@ export const PostDetailScreen: React.FC = () => {
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={refreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMoreComments}
onEndReachedThreshold={0.3}
ListFooterComponent={
isCommentsLoading ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : hasMoreComments ? (
<TouchableOpacity
style={styles.loadMoreButton}
onPress={loadMoreComments}
>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : comments.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
</Text>
) : null
}
/>
);
@@ -1433,7 +1480,7 @@ export const PostDetailScreen: React.FC = () => {
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={refreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
@@ -1465,7 +1512,7 @@ export const PostDetailScreen: React.FC = () => {
// 移动端单栏布局
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={88}
@@ -1486,12 +1533,34 @@ export const PostDetailScreen: React.FC = () => {
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={refreshing || isCommentsRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMoreComments}
onEndReachedThreshold={0.3}
ListFooterComponent={
isCommentsLoading ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : hasMoreComments ? (
<TouchableOpacity
style={styles.loadMoreButton}
onPress={loadMoreComments}
>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : comments.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
</Text>
) : null
}
/>
{/* 评论输入框 - 跟随键盘 */}
@@ -1905,4 +1974,22 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
textAlign: 'center',
},
// 评论加载更多样式
commentsLoadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
loadMoreButton: {
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
noMoreComments: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});

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,
},
});

View File

@@ -2,6 +2,7 @@
* GroupMembersScreen 群成员管理界面
* 显示群成员列表,支持管理员管理成员
* 支持响应式网格布局
* 使用游标分页
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
@@ -27,6 +28,7 @@ import { groupService } from '../../services/groupService';
import { groupManager } from '../../stores/groupManager';
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import {
GroupMemberResponse,
GroupRole,
@@ -68,12 +70,34 @@ const GroupMembersScreen: React.FC = () => {
return GRID_CONFIG.mobile;
}, [width]);
// 成员列表状态
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
// 使用游标分页 Hook 管理成员列表
const {
items: members,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await groupService.getGroupMembersCursor(groupId, {
cursor,
page_size: pageSize,
});
},
{ pageSize: 50 }
);
// 本地成员状态(用于乐观更新)
const [localMembers, setLocalMembers] = useState<GroupMemberResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// 同步分页数据到本地状态
useEffect(() => {
setLocalMembers(members);
setLoading(false);
}, [members]);
// 当前用户的成员信息
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
@@ -91,65 +115,31 @@ const GroupMembersScreen: React.FC = () => {
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;
// 加载成员列表
const loadMembers = useCallback(
async (
pageNum: number = 1,
refresh: boolean = false,
forceRefresh: boolean = false
) => {
if (!hasMore && !refresh) return;
try {
const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh);
if (refresh) {
setMembers(response.list);
setPage(1);
} else {
setMembers(prev => [...prev, ...response.list]);
}
setHasMore(response.list.length === 50);
// 查找当前用户的成员信息
const myMember = response.list.find(m => m.user_id === currentUser?.id);
if (myMember) {
setCurrentMember(myMember);
}
} catch (error) {
console.error('加载成员列表失败:', error);
// 查找当前用户的成员信息
useEffect(() => {
const myMember = localMembers.find(m => m.user_id === currentUser?.id);
if (myMember) {
setCurrentMember(myMember);
}
}, [localMembers, currentUser]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setLoading(true);
await refresh();
setLoading(false);
setRefreshing(false);
}, [groupId, currentUser, hasMore]);
}, [refresh]);
// 初始加载
useEffect(() => {
loadMembers(1, true, true);
refresh();
}, [groupId]);
// 下拉刷新
const onRefresh = useCallback(() => {
setRefreshing(true);
setHasMore(true);
loadMembers(1, true, true);
}, [loadMembers]);
// 加载更多
const loadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
loadMembers(nextPage);
}
}, [loading, hasMore, page, loadMembers]);
// 按角色分组
const groupMembers = useCallback((): MemberGroup[] => {
const owners = members.filter(m => m.role === 'owner');
const admins = members.filter(m => m.role === 'admin');
const normalMembers = members.filter(m => m.role === 'member');
const owners = localMembers.filter(m => m.role === 'owner');
const admins = localMembers.filter(m => m.role === 'admin');
const normalMembers = localMembers.filter(m => m.role === 'member');
const groups: MemberGroup[] = [];
@@ -164,7 +154,7 @@ const GroupMembersScreen: React.FC = () => {
}
return groups;
}, [members]);
}, [localMembers]);
// 打开操作菜单
const openActionModal = (member: GroupMemberResponse) => {
@@ -203,7 +193,7 @@ const GroupMembersScreen: React.FC = () => {
});
// 更新本地数据
setMembers(prev => prev.map(m => {
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, role: newRole };
}
@@ -245,14 +235,14 @@ const GroupMembersScreen: React.FC = () => {
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
// 更新本地数据
setMembers(prev => prev.map(m => {
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, muted: newMuted };
}
return m;
}));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await loadMembers(1, true, true);
await refresh();
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);
@@ -286,7 +276,7 @@ const GroupMembersScreen: React.FC = () => {
await groupService.removeMember(groupId, selectedMember.user_id);
// 更新本地数据
setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
setActionModalVisible(false);
Alert.alert('成功', '已移除成员');
@@ -320,7 +310,7 @@ const GroupMembersScreen: React.FC = () => {
});
// 更新本地数据
setMembers(prev => prev.map(m => {
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, nickname: newNickname.trim() };
}
@@ -422,7 +412,7 @@ const GroupMembersScreen: React.FC = () => {
// 渲染空状态
const renderEmpty = () => {
if (loading) return <Loading />;
if (loading || isLoading) return <Loading />;
return (
<EmptyState
@@ -589,7 +579,7 @@ const GroupMembersScreen: React.FC = () => {
keyExtractor={(item) => item.title}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={isRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
@@ -598,6 +588,23 @@ const GroupMembersScreen: React.FC = () => {
onEndReached={loadMore}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}>
<Loading size="sm" />
</View>
) : hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : localMembers.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
</Text>
) : null
}
renderItem={({ item: group }) => (
<View style={styles.section}>
{renderSectionHeader(group.title, group.data.length)}
@@ -724,6 +731,19 @@ const styles = StyleSheet.create({
flex: 1,
marginHorizontal: spacing.xs,
},
// 分页加载样式
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});
export default GroupMembersScreen;

View File

@@ -1,5 +1,15 @@
import React, { useState } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native';
import React, { useState, useCallback } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
Clipboard,
FlatList,
RefreshControl,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -11,6 +21,8 @@ import { groupService } from '../../services/groupService';
import { groupManager } from '../../stores/groupManager';
import { RootStackParamList } from '../../navigation/types';
import { GroupResponse, JoinType } from '../../types/dto';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { EmptyState } from '../../components/common';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
@@ -18,10 +30,29 @@ const JoinGroupScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const [keyword, setKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [joining, setJoining] = useState(false);
const [group, setGroup] = useState<GroupResponse | null>(null);
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
const [searched, setSearched] = useState(false);
// 使用游标分页 Hook 管理群组列表
const {
items: groups,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await groupService.getGroupsCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
const getJoinTypeText = (joinType: JoinType) => {
if (joinType === 0) return '允许加入';
if (joinType === 1) return '需要审批';
@@ -39,9 +70,9 @@ const JoinGroupScreen: React.FC = () => {
setSearched(true);
try {
const result = await groupManager.getGroup(trimmed, true);
setGroup(result);
setSearchedGroup(result);
} catch (error: any) {
setGroup(null);
setSearchedGroup(null);
const message = error?.response?.data?.message || error?.message || '';
if (String(message).includes('不存在') || error?.response?.status === 404) {
Alert.alert('未找到', '未搜索到该群聊请确认群ID是否正确');
@@ -53,9 +84,9 @@ const JoinGroupScreen: React.FC = () => {
}
};
const handleJoin = async () => {
const handleJoin = async (group: GroupResponse) => {
if (!group?.id) return;
setJoining(true);
setJoiningGroupId(String(group.id));
try {
await groupService.joinGroup(group.id);
Alert.alert('成功', '操作已提交', [
@@ -71,13 +102,12 @@ const JoinGroupScreen: React.FC = () => {
'操作失败,请稍后重试';
Alert.alert('操作失败', String(message));
} finally {
setJoining(false);
setJoiningGroupId(null);
}
};
const handleCopyGroupId = () => {
if (!group?.id) return;
Clipboard.setString(String(group.id));
const handleCopyGroupId = (groupId: string | number) => {
Clipboard.setString(String(groupId));
Alert.alert('已复制', '群号已复制到剪贴板');
};
@@ -87,6 +117,99 @@ const JoinGroupScreen: React.FC = () => {
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
};
const renderGroupItem = ({ item: group }: { item: GroupResponse }) => {
const isJoining = joiningGroupId === String(group.id);
return (
<View style={styles.groupCard}>
<View style={styles.groupHeader}>
<Avatar source={group.avatar} size={52} name={group.name} />
<View style={styles.groupMeta}>
<Text variant="body" style={styles.groupName} numberOfLines={1}>
{group.name}
</Text>
</View>
</View>
{!!group.description && (
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
{group.description}
</Text>
)}
<View style={styles.groupInfoRow}>
<Text variant="caption" color={colors.text.secondary}>
{group.member_count}/{group.max_members}
</Text>
<Text variant="caption" color={colors.text.secondary}>
{getJoinTypeText(group.join_type)}
</Text>
</View>
<View style={styles.groupNoRow}>
<Text variant="caption" color={colors.text.secondary}>
{formatGroupNo(group.id)}
</Text>
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
onPress={() => handleJoin(group)}
disabled={isJoining}
>
{isJoining ? (
<ActivityIndicator color="#fff" />
) : (
<>
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
<Text variant="body" color="#fff" style={styles.submitText}>
</Text>
</>
)}
</TouchableOpacity>
</View>
);
};
const renderEmptyList = () => {
if (isLoading) return null;
return (
<EmptyState
title="暂无群组"
description="还没有可加入的群组"
icon="account-group-outline"
/>
);
};
const renderSearchResult = () => {
if (!searched) return null;
if (searchedGroup) {
return (
<View style={styles.searchResultSection}>
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
{renderGroupItem({ item: searchedGroup })}
</View>
);
}
if (!searching) {
return (
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
ID后重试
</Text>
);
}
return null;
};
return (
<View style={styles.container}>
<View style={styles.heroCard}>
@@ -100,23 +223,25 @@ const JoinGroupScreen: React.FC = () => {
</View>
<View style={styles.formCard}>
<Text variant="caption" color={colors.text.secondary} style={styles.label}>ID</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
ID
</Text>
<View style={styles.searchRow}>
<TextInput
value={keyword}
onChangeText={setKeyword}
placeholder="例如7391234567890"
placeholderTextColor={colors.text.hint}
placeholder="例如7391234567890"
placeholderTextColor={colors.text.hint}
style={styles.input}
editable={!searching && !joining}
autoCapitalize="none"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
editable={!searching && !joiningGroupId}
autoCapitalize="none"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
<TouchableOpacity
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
onPress={handleSearch}
disabled={!keyword.trim() || searching || joining}
disabled={!keyword.trim() || searching || !!joiningGroupId}
>
{searching ? (
<ActivityIndicator color="#fff" />
@@ -126,58 +251,49 @@ const JoinGroupScreen: React.FC = () => {
</TouchableOpacity>
</View>
{group && (
<View style={styles.groupCard}>
<View style={styles.groupHeader}>
<Avatar source={group.avatar} size={52} name={group.name} />
<View style={styles.groupMeta}>
<Text variant="body" style={styles.groupName} numberOfLines={1}>{group.name}</Text>
</View>
</View>
{!!group.description && (
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
{group.description}
</Text>
)}
<View style={styles.groupInfoRow}>
<Text variant="caption" color={colors.text.secondary}>
{group.member_count}/{group.max_members}
</Text>
<Text variant="caption" color={colors.text.secondary}>
{getJoinTypeText(group.join_type)}
</Text>
</View>
<View style={styles.groupNoRow}>
<Text variant="caption" color={colors.text.secondary}>
{formatGroupNo(group.id)}
</Text>
<TouchableOpacity style={styles.copyBtn} onPress={handleCopyGroupId}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}></Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.submitBtn, joining && styles.submitBtnDisabled]}
onPress={handleJoin}
disabled={joining}
>
{joining ? (
<ActivityIndicator color="#fff" />
) : (
<>
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
<Text variant="body" color="#fff" style={styles.submitText}></Text>
</>
)}
</TouchableOpacity>
</View>
)}
{/* 搜索结果 */}
{renderSearchResult()}
{searched && !group && !searching && (
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
ID后重试
{/* 群组列表 */}
<View style={styles.listSection}>
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
</Text>
)}
<FlatList
data={groups}
renderItem={renderGroupItem}
keyExtractor={(item) => String(item.id)}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmptyList}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}>
<ActivityIndicator color={colors.primary.main} />
</View>
) : hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : groups.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
</Text>
) : null
}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
</View>
);
@@ -214,6 +330,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
flex: 1,
},
label: {
marginBottom: spacing.xs,
@@ -242,12 +359,23 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchResultSection: {
marginBottom: spacing.lg,
},
sectionTitle: {
marginBottom: spacing.sm,
fontWeight: '600',
},
listSection: {
flex: 1,
},
groupCard: {
borderWidth: 1,
borderColor: colors.divider,
borderRadius: borderRadius.md,
padding: spacing.md,
backgroundColor: colors.background.default,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
@@ -259,6 +387,7 @@ const styles = StyleSheet.create({
},
groupName: {
marginBottom: spacing.xs,
fontWeight: '600',
},
groupDesc: {
marginTop: spacing.sm,
@@ -303,6 +432,19 @@ const styles = StyleSheet.create({
},
emptyText: {
marginTop: spacing.sm,
textAlign: 'center',
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
},
});

View File

@@ -30,12 +30,13 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto';
import { authService } from '../../services';
import { authService, messageService } from '../../services';
import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks
import { useMessageList, messageManager, useMessageListRefresh, useCreateConversation } from '../../stores';
import { messageManager, useMessageListRefresh, useCreateConversation, useUnreadCount, useMarkAsRead } from '../../stores';
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
import { getUserCache } from '../../services/database';
// 导入 EmbeddedChat 组件用于桌面端双栏布局
@@ -160,22 +161,43 @@ export const MessageListScreen: React.FC = () => {
const { isDesktop, isTablet, width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 【新架构】使用MessageManager的hook获取数据
// 【游标分页】使用 useCursorPagination hook 获取会话列表
const {
conversations,
items: conversations,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
totalUnreadCount,
systemUnreadCount,
markAllAsRead,
isMarking,
} = useMessageList();
error: paginationError,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await messageService.getConversationsCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
// 使用 MessageManager 获取未读数和系统通知数
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
const { markAllAsRead, isMarking } = useMarkAsRead(null);
// 【新架构】使用MessageManager的hook创建会话
const { createConversation } = useCreateConversation();
// 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// 上拉加载更多
const onEndReached = useCallback(async () => {
if (isLoading || loadingMore || !hasMore) return;
setLoadingMore(true);
await loadMore();
setLoadingMore(false);
}, [isLoading, loadingMore, hasMore, loadMore]);
// 搜索相关状态
const [isSearchMode, setIsSearchMode] = useState(false);
@@ -885,7 +907,7 @@ export const MessageListScreen: React.FC = () => {
</View>
</TouchableOpacity>
{isLoading ? (
{isLoading && conversations.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
@@ -901,6 +923,8 @@ export const MessageListScreen: React.FC = () => {
]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -909,6 +933,14 @@ export const MessageListScreen: React.FC = () => {
tintColor={colors.primary.main}
/>
}
ListFooterComponent={
loadingMore ? (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text style={styles.loadingMoreText}>...</Text>
</View>
) : null
}
/>
)}
</>
@@ -1261,6 +1293,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: spacing.xl * 2,
},
loadingMoreContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
},
loadingMoreText: {
marginLeft: spacing.sm,
fontSize: 14,
color: '#999',
},
searchModeContainer: {
flex: 1,
backgroundColor: '#FAFAFA',

View File

@@ -1,7 +1,7 @@
/**
* 通知页 NotificationsScreen
* 胡萝卜BBS - 系统消息列表
* 使用新的系统消息API
* 【游标分页】使用 messageService.getSystemMessagesCursor
* 支持响应式布局
*/
@@ -26,6 +26,7 @@ import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService';
import { SystemMessageItem } from '../../components/business';
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { RootStackParamList } from '../../navigation/types';
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
@@ -70,14 +71,32 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
// Web端使用更大的容器宽度
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
const [activeType, setActiveType] = useState('all');
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表
const {
items: messages,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error: paginationError,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await messageService.getSystemMessagesCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
// 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
const displayMessages = useMemo(() => {
const reviewedByFlag = new Map<string, SystemMessageResponse>();
@@ -116,21 +135,6 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
});
}, [messages]);
// 获取系统消息数据
const fetchMessages = useCallback(async () => {
try {
setLoading(true);
const response = await messageService.getSystemMessages(50, 1);
// 添加防御性检查,确保 messages 数组存在
setMessages(response.messages || []);
setHasMore(response.has_more ?? false);
} catch (error) {
console.error('获取系统消息失败:', error);
} finally {
setLoading(false);
}
}, []);
// 获取未读数
const fetchUnreadCount = useCallback(async () => {
try {
@@ -145,27 +149,25 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const handleMarkAllRead = useCallback(async () => {
try {
await messageService.markAllSystemMessagesRead();
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
// 刷新消息列表
await refresh();
setUnreadCount(0);
setSystemUnreadCount(0);
// 同步更新全局 TabBar 红点
fetchMessageUnreadCount();
// 刷新消息列表
fetchMessages();
} catch (error) {
console.error('一键已读失败:', error);
}
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
}, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]);
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
useEffect(() => {
if (isFocused) {
fetchMessages();
fetchUnreadCount();
// 进入界面自动标记所有消息为已读
handleMarkAllRead();
}
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
}, [isFocused, fetchUnreadCount, handleMarkAllRead]);
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
@@ -190,29 +192,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
// 下拉刷新
const onRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.all([fetchMessages(), fetchUnreadCount()]);
await Promise.all([refresh(), fetchUnreadCount()]);
setRefreshing(false);
}, [fetchMessages, fetchUnreadCount]);
}, [refresh, fetchUnreadCount]);
// 加载更多
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || messages.length === 0) return;
try {
setLoadingMore(true);
// 使用时间戳或seq作为游标分页后端使用page分页
const nextPage = Math.floor((messages.length / 20)) + 1;
const response = await messageService.getSystemMessages(20, nextPage);
// 添加防御性检查
const newMessages = response.messages || [];
setMessages(prev => [...prev, ...newMessages]);
setHasMore(response.has_more ?? false);
} catch (error) {
console.error('加载更多失败:', error);
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, messages]);
const onEndReached = useCallback(async () => {
if (isLoading || loadingMore || !hasMore) return;
setLoadingMore(true);
await loadMore();
setLoadingMore(false);
}, [isLoading, loadingMore, hasMore, loadMore]);
// 标记单条消息已读并处理导航
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
@@ -262,9 +252,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const messageId = String(message.id);
const wasUnread = message.is_read !== true;
await messageService.markSystemMessageRead(messageId);
setMessages(prev =>
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
);
// 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
decrementSystemUnreadCount(1);
@@ -431,7 +419,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
</View>
{/* 消息列表 */}
{loading ? (
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
@@ -444,7 +432,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={loadMore}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
@@ -505,7 +493,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
</View>
{/* 消息列表 */}
{loading ? (
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
@@ -518,7 +506,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={loadMore}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl