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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ dist-android-update.zip
|
||||
# Backend
|
||||
backend/data/
|
||||
backend/logs/
|
||||
|
||||
doc/
|
||||
docs/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1131
plans/frontend_cursor_pagination_design.md
Normal file
1131
plans/frontend_cursor_pagination_design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,9 @@ export type {
|
||||
UsePaginationReturn,
|
||||
} from './usePagination';
|
||||
|
||||
// ==================== 游标分页 Hooks ====================
|
||||
export { useCursorPagination } from './useCursorPagination';
|
||||
|
||||
// ==================== 连接状态 Hooks ====================
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
|
||||
|
||||
208
src/hooks/useCursorPagination.ts
Normal file
208
src/hooks/useCursorPagination.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
CursorPaginationState,
|
||||
CursorPaginationConfig,
|
||||
CursorDirection,
|
||||
UseCursorPaginationReturn,
|
||||
CursorFetchFunction,
|
||||
} from '../infrastructure/pagination/types';
|
||||
import { CursorPaginationResponse } from '../types/dto';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* 游标分页 Hook
|
||||
*
|
||||
* @param fetchFunction 数据获取函数
|
||||
* @param config 分页配置
|
||||
* @param extraParams 额外参数(会传递给 fetchFunction)
|
||||
*/
|
||||
export function useCursorPagination<T, P = void>(
|
||||
fetchFunction: CursorFetchFunction<T, P>,
|
||||
config: Partial<CursorPaginationConfig> = {},
|
||||
extraParams?: P
|
||||
): UseCursorPaginationReturn<T> {
|
||||
const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config;
|
||||
|
||||
// 限制 pageSize 在有效范围内
|
||||
const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
|
||||
|
||||
const [state, setState] = useState<CursorPaginationState<T>>({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
isFirstLoad: true,
|
||||
});
|
||||
|
||||
// 用于取消请求的标志
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// 加载更多(下一页)
|
||||
const loadMore = useCallback(async () => {
|
||||
if (state.isLoading || !state.hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||
cursor: state.nextCursor || undefined,
|
||||
direction: 'forward',
|
||||
pageSize: effectivePageSize,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
items: [...prev.items, ...response.items],
|
||||
nextCursor: response.next_cursor,
|
||||
prevCursor: response.prev_cursor,
|
||||
hasMore: response.has_more && response.next_cursor !== null,
|
||||
isLoading: false,
|
||||
isFirstLoad: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : '加载失败',
|
||||
}));
|
||||
}
|
||||
}, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]);
|
||||
|
||||
// 加载上一页(双向分页)
|
||||
const loadPrevious = useCallback(async () => {
|
||||
if (!bidirectional || state.isLoading || !state.prevCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||
cursor: state.prevCursor,
|
||||
direction: 'backward',
|
||||
pageSize: effectivePageSize,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
// 向前加载时,新数据放在前面
|
||||
items: [...response.items, ...prev.items],
|
||||
nextCursor: response.next_cursor,
|
||||
prevCursor: response.prev_cursor,
|
||||
hasMore: response.has_more,
|
||||
isLoading: false,
|
||||
isFirstLoad: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : '加载失败',
|
||||
}));
|
||||
}
|
||||
}, [bidirectional, state.isLoading, state.prevCursor, fetchFunction, effectivePageSize, extraParams]);
|
||||
|
||||
// 刷新数据
|
||||
const refresh = useCallback(async () => {
|
||||
cancelledRef.current = false;
|
||||
|
||||
setState(prev => ({ ...prev, isRefreshing: true, error: null }));
|
||||
|
||||
try {
|
||||
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||
cursor: undefined,
|
||||
direction: 'forward',
|
||||
pageSize: effectivePageSize,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState({
|
||||
items: response.items,
|
||||
nextCursor: response.next_cursor,
|
||||
prevCursor: response.prev_cursor,
|
||||
hasMore: response.has_more && response.next_cursor !== null,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
isFirstLoad: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isRefreshing: false,
|
||||
error: error instanceof Error ? error.message : '刷新失败',
|
||||
}));
|
||||
}
|
||||
}, [fetchFunction, effectivePageSize, extraParams]);
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
setState({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
isFirstLoad: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 设置数据(用于外部数据注入)
|
||||
const setItems = useCallback(
|
||||
(
|
||||
items: T[],
|
||||
nextCursor: string | null,
|
||||
prevCursor: string | null,
|
||||
hasMore: boolean
|
||||
) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
items,
|
||||
nextCursor,
|
||||
prevCursor,
|
||||
hasMore,
|
||||
isFirstLoad: false,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
loadMore,
|
||||
loadPrevious,
|
||||
refresh,
|
||||
reset,
|
||||
setItems,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCursorPagination;
|
||||
@@ -19,6 +19,16 @@ export type {
|
||||
FetchPageFunction,
|
||||
} from './types';
|
||||
|
||||
// 导出游标分页相关类型
|
||||
export type {
|
||||
CursorDirection,
|
||||
CursorPaginationConfig,
|
||||
CursorPaginationState,
|
||||
CursorPaginationActions,
|
||||
UseCursorPaginationReturn,
|
||||
CursorFetchFunction,
|
||||
} from './types';
|
||||
|
||||
// 导出工具函数和常量
|
||||
export {
|
||||
DEFAULT_PAGINATION_CONFIG,
|
||||
|
||||
@@ -180,3 +180,79 @@ export function createPageCache<T>(
|
||||
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
|
||||
return Date.now() - cache.cachedAt > ttl;
|
||||
}
|
||||
|
||||
// ==================== 游标分页相关类型 ====================
|
||||
|
||||
/**
|
||||
* 游标分页方向
|
||||
*/
|
||||
export type CursorDirection = 'forward' | 'backward';
|
||||
|
||||
/**
|
||||
* 游标分页配置
|
||||
*/
|
||||
export interface CursorPaginationConfig {
|
||||
/** 每页数量 */
|
||||
pageSize: number;
|
||||
/** 是否启用双向分页 */
|
||||
bidirectional?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页状态
|
||||
*/
|
||||
export interface CursorPaginationState<T> {
|
||||
/** 数据项列表 */
|
||||
items: T[];
|
||||
/** 下一页游标 */
|
||||
nextCursor: string | null;
|
||||
/** 上一页游标 */
|
||||
prevCursor: string | null;
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 是否正在刷新 */
|
||||
isRefreshing: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 是否为首次加载 */
|
||||
isFirstLoad: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页操作
|
||||
*/
|
||||
export interface CursorPaginationActions<T> {
|
||||
/** 加载更多(下一页) */
|
||||
loadMore: () => Promise<void>;
|
||||
/** 加载上一页(双向分页) */
|
||||
loadPrevious: () => Promise<void>;
|
||||
/** 刷新数据(重新从第一页加载) */
|
||||
refresh: () => Promise<void>;
|
||||
/** 重置状态 */
|
||||
reset: () => void;
|
||||
/** 设置数据(用于外部数据注入) */
|
||||
setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页 Hook 返回值
|
||||
*/
|
||||
export interface UseCursorPaginationReturn<T> extends CursorPaginationState<T>, CursorPaginationActions<T> {}
|
||||
|
||||
/**
|
||||
* 游标分页数据获取函数
|
||||
*/
|
||||
export type CursorFetchFunction<T, P = void> = (params: {
|
||||
cursor?: string;
|
||||
direction: CursorDirection;
|
||||
pageSize: number;
|
||||
/** 额外参数 */
|
||||
extraParams?: P;
|
||||
}) => Promise<{
|
||||
items: T[];
|
||||
next_cursor: string | null;
|
||||
prev_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { api, PaginatedData } from './api';
|
||||
import { Comment, CreateCommentInput } from '../types';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '../types/dto';
|
||||
|
||||
// 评论列表响应
|
||||
interface CommentListResponse {
|
||||
@@ -240,6 +241,62 @@ class CommentService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
/**
|
||||
* 获取帖子评论列表(游标分页)
|
||||
* GET /api/v1/comments/post/:id/cursor
|
||||
* @param postId 帖子ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getPostCommentsCursor(
|
||||
postId: string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Comment>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Comment>>(
|
||||
`/comments/post/${postId}/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取帖子评论列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评论回复列表(游标分页)
|
||||
* GET /api/v1/comments/:id/replies/cursor
|
||||
* @param commentId 评论ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getCommentRepliesCursor(
|
||||
commentId: string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Comment>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Comment>>(
|
||||
`/comments/${commentId}/replies/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取评论回复列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出评论服务实例
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
MyMemberInfoResponse,
|
||||
SetGroupAvatarRequest,
|
||||
HandleGroupRequestAction,
|
||||
CursorPaginationRequest,
|
||||
CursorPaginationResponse,
|
||||
} from '../types/dto';
|
||||
|
||||
// 群组服务类(纯 API 层)
|
||||
@@ -321,6 +323,86 @@ class GroupService {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
/**
|
||||
* 获取群组列表(游标分页)
|
||||
* GET /api/v1/groups/cursor
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getGroupsCursor(
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<GroupResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<GroupResponse>>('/groups/cursor', {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取群组列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组成员列表(游标分页)
|
||||
* GET /api/v1/groups/:id/members/cursor
|
||||
* @param groupId 群组ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getGroupMembersCursor(
|
||||
groupId: number | string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<GroupMemberResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<GroupMemberResponse>>(
|
||||
`/groups/${encodeURIComponent(String(groupId))}/members/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取群组成员列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群公告列表(游标分页)
|
||||
* GET /api/v1/groups/:id/announcements/cursor
|
||||
* @param groupId 群组ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getGroupAnnouncementsCursor(
|
||||
groupId: number | string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<GroupAnnouncementResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<GroupAnnouncementResponse>>(
|
||||
`/groups/${encodeURIComponent(String(groupId))}/announcements/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取群公告列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
UnreadCountResponse,
|
||||
ConversationUnreadCountResponse,
|
||||
SystemMessageListResponse,
|
||||
SystemMessageResponse,
|
||||
SystemUnreadCountResponse,
|
||||
MessageSegment,
|
||||
CursorPaginationRequest,
|
||||
CursorPaginationResponse,
|
||||
} from '../types/dto';
|
||||
import {
|
||||
getConversationCache,
|
||||
@@ -555,6 +558,85 @@ class MessageService {
|
||||
await api.put('/messages/system/read-all');
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
/**
|
||||
* 获取会话列表(游标分页)
|
||||
* GET /api/v1/conversations/cursor
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getConversationsCursor(
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<ConversationResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<ConversationResponse>>(
|
||||
'/conversations/cursor',
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取会话列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表(游标分页)
|
||||
* GET /api/v1/conversations/:id/messages/cursor
|
||||
* @param conversationId 会话ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getMessagesCursor(
|
||||
conversationId: string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<MessageResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<MessageResponse>>(
|
||||
`/conversations/${encodeURIComponent(conversationId)}/messages/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取消息列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统消息列表(游标分页)
|
||||
* GET /api/v1/messages/system/cursor
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getSystemMessagesCursor(
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<SystemMessageResponse>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<SystemMessageResponse>>(
|
||||
'/messages/system/cursor',
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取系统消息列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { api, PaginatedData } from './api';
|
||||
import { Notification, NotificationBadge, NotificationType } from '../types';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto';
|
||||
|
||||
// 通知列表响应
|
||||
interface NotificationListResponse {
|
||||
@@ -151,6 +152,33 @@ class NotificationService {
|
||||
async getMentionNotifications(page = 1, pageSize = 20): Promise<PaginatedData<Notification>> {
|
||||
return this.getNotifications(page, pageSize, 'mention');
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
/**
|
||||
* 获取通知列表(游标分页)
|
||||
* GET /api/v1/notifications/cursor
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getNotificationsCursor(
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Notification>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Notification>>(
|
||||
'/notifications/cursor',
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出通知服务实例
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { api, PaginatedData } from './api';
|
||||
import { Post, CreatePostInput } from '../types';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto';
|
||||
|
||||
// 帖子列表响应
|
||||
interface PostListResponse {
|
||||
@@ -281,6 +282,92 @@ class PostService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
/**
|
||||
* 获取帖子列表(游标分页)
|
||||
* GET /api/v1/posts/cursor
|
||||
* @param params 游标分页请求参数(包含 post_type 可选:recommend, follow, hot, latest)
|
||||
*/
|
||||
async getPostsCursor(
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Post>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Post>>('/posts/cursor', {
|
||||
params: {
|
||||
cursor: params.cursor,
|
||||
page_size: params.page_size,
|
||||
post_type: params.post_type,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索帖子(游标分页)
|
||||
* GET /api/v1/posts/search/cursor
|
||||
* @param query 搜索关键词
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async searchPostsCursor(
|
||||
query: string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Post>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Post>>('/posts/search/cursor', {
|
||||
params: {
|
||||
...params,
|
||||
keyword: query,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('搜索帖子失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户帖子列表(游标分页)
|
||||
* GET /api/v1/users/:id/posts/cursor
|
||||
* @param userId 用户ID
|
||||
* @param params 游标分页请求参数
|
||||
*/
|
||||
async getUserPostsCursor(
|
||||
userId: string,
|
||||
params: CursorPaginationRequest = {}
|
||||
): Promise<CursorPaginationResponse<Post>> {
|
||||
try {
|
||||
const response = await api.get<CursorPaginationResponse<Post>>(
|
||||
`/users/${userId}/posts/cursor`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子列表失败:', error);
|
||||
return {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出帖子服务实例
|
||||
|
||||
@@ -499,6 +499,36 @@ export interface SystemUnreadCountResponse {
|
||||
// 设备类型
|
||||
export type DeviceType = 'ios' | 'android' | 'web';
|
||||
|
||||
// ==================== 游标分页相关 DTO ====================
|
||||
|
||||
/**
|
||||
* 游标分页请求参数
|
||||
*/
|
||||
export interface CursorPaginationRequest {
|
||||
/** 游标字符串(可选,首次请求不传) */
|
||||
cursor?: string;
|
||||
/** 分页方向:forward 或 backward(默认 forward) */
|
||||
direction?: 'forward' | 'backward';
|
||||
/** 每页数量(默认 20,最大 100) */
|
||||
page_size?: number;
|
||||
/** 帖子类型筛选(可选):recommend, follow, hot, latest */
|
||||
post_type?: 'recommend' | 'follow' | 'hot' | 'latest';
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页响应
|
||||
*/
|
||||
export interface CursorPaginationResponse<T> {
|
||||
/** 数据项列表 */
|
||||
items: T[];
|
||||
/** 下一页游标 */
|
||||
next_cursor: string | null;
|
||||
/** 上一页游标 */
|
||||
prev_cursor: string | null;
|
||||
/** 是否有更多数据 */
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
// 设备Token响应
|
||||
export interface DeviceTokenResponse {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user