refactor: standardize pagination response structure and enhance cursor handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m8s
Frontend CI / ota-android (push) Successful in 11m22s
Frontend CI / build-android-apk (push) Successful in 45m46s

- Update various services and hooks to replace 'items' with 'list' for consistency in pagination responses.
- Modify PostRepository, commentService, groupService, messageService, and postService to align with the new response structure.
- Enhance cursor pagination logic in hooks and components to ensure proper handling of pagination states.
- Refactor HomeScreen, PostDetailScreen, SearchScreen, and other components to utilize the updated pagination structure.
This commit is contained in:
lafay
2026-03-23 03:58:26 +08:00
parent 88510aa6ae
commit 26ae288470
19 changed files with 312 additions and 309 deletions

View File

@@ -314,8 +314,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64 platforms: linux/amd64
provenance: false provenance: false
cache-from: type=gha cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
cache-to: type=gha,mode=max cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
- name: Show image tags - name: Show image tags
run: | run: |

View File

@@ -64,10 +64,14 @@ export interface PostsState {
hasMore: boolean; hasMore: boolean;
/** 错误信息 */ /** 错误信息 */
error: string | null; error: string | null;
/** 当前游标 */ /** 当前游标cursor 模式) */
cursor: string | null; cursor: string | null;
/** 当前页码page 模式) */
currentPage: number;
/** 最后加载时间 */ /** 最后加载时间 */
lastLoadTime: number | null; lastLoadTime: number | null;
/** 上次请求的参数(用于加载更多和刷新) */
lastParams?: GetPostsParams;
} }
/** /**
@@ -198,6 +202,7 @@ class ProcessPostUseCase {
hasMore: true, hasMore: true,
error: null, error: null,
cursor: null, cursor: null,
currentPage: 1,
lastLoadTime: null, lastLoadTime: null,
}; };
this.postsState.set(key, state); this.postsState.set(key, state);
@@ -257,29 +262,32 @@ class ProcessPostUseCase {
* @param key 列表键(用于区分不同的列表) * @param key 列表键(用于区分不同的列表)
*/ */
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> { async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
const state = this.getPostsState(key);
// 更新加载状态 // 更新加载状态
this.updatePostsState(key, { isLoading: true, error: null }); this.updatePostsState(key, { isLoading: true, error: null });
try { try {
// 合并分页参数 // 默认传递空字符串 cursor 来启动 cursor 模式
// 注意cursor 要放在 ...params 后面,这样只有在 params 没有传 cursor 时才使用默认值
const queryParams: GetPostsParams = { const queryParams: GetPostsParams = {
page: params?.page || 1,
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
cursor: params?.cursor || state.cursor || undefined,
...params, ...params,
cursor: params?.cursor !== undefined ? params.cursor : '', // 默认使用空字符串启动 cursor 模式
}; };
const result = await postRepository.getPosts(queryParams); const result = await postRepository.getPosts(queryParams);
// 更新状态 // 判断是否为 cursor 模式(有 next_cursor 返回)
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
// 更新状态(保存请求参数以便加载更多时使用)
this.updatePostsState(key, { this.updatePostsState(key, {
posts: result.posts, posts: result.posts,
hasMore: result.hasMore, hasMore: result.hasMore,
cursor: result.nextCursor || null, cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor否则设为 null 表示 page 模式
currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始
isLoading: false, isLoading: false,
lastLoadTime: Date.now(), lastLoadTime: Date.now(),
lastParams: params, // 保存请求参数
}); });
// 通知订阅者 // 通知订阅者
@@ -312,20 +320,29 @@ class ProcessPostUseCase {
* @param key 列表键 * @param key 列表键
*/ */
async refreshPosts(key: string = 'default'): Promise<PostsResult> { async refreshPosts(key: string = 'default'): Promise<PostsResult> {
const state = this.getPostsState(key);
// 更新刷新状态 // 更新刷新状态
this.updatePostsState(key, { isRefreshing: true, error: null }); this.updatePostsState(key, { isRefreshing: true, error: null });
try { try {
// 使用保存的请求参数(如 post_type进行刷新
// 默认传递空字符串 cursor 来启动 cursor 模式
const result = await postRepository.getPosts({ const result = await postRepository.getPosts({
page: 1,
pageSize: DEFAULT_PAGE_SIZE, pageSize: DEFAULT_PAGE_SIZE,
...state.lastParams,
cursor: state.lastParams?.cursor !== undefined ? state.lastParams.cursor : '', // 默认使用空字符串启动 cursor 模式
}); });
// 判断是否为 cursor 模式(有 next_cursor 返回)
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
// 更新状态 // 更新状态
this.updatePostsState(key, { this.updatePostsState(key, {
posts: result.posts, posts: result.posts,
hasMore: result.hasMore, hasMore: result.hasMore,
cursor: result.nextCursor || null, cursor: isCursorMode ? result.nextCursor : null,
currentPage: isCursorMode ? undefined : 1,
isRefreshing: false, isRefreshing: false,
lastLoadTime: Date.now(), lastLoadTime: Date.now(),
}); });
@@ -372,21 +389,45 @@ class ProcessPostUseCase {
this.updatePostsState(key, { isLoading: true, error: null }); this.updatePostsState(key, { isLoading: true, error: null });
try { try {
const queryParams: GetPostsParams = { // 判断使用哪种分页模式cursor 不为 null 表示使用 cursor 模式
const useCursorMode = state.cursor !== null && state.cursor !== undefined;
// 必须先展开 lastParams再由本次分页字段覆盖。
// lastParams 来自首次 fetch通常含 page:1若写在展开之前会把 nextPage/cursor 覆盖掉,导致永远请求第 1 页、无法加载第 3 页及以后。
const base = { ...(state.lastParams ?? {}) } as GetPostsParams;
let queryParams: GetPostsParams;
if (useCursorMode) {
delete base.page;
queryParams = {
...base,
pageSize: DEFAULT_PAGE_SIZE, pageSize: DEFAULT_PAGE_SIZE,
cursor: state.cursor || undefined, cursor: state.cursor!,
}; };
} else {
const nextPage = (state.currentPage || 1) + 1;
delete base.cursor;
queryParams = {
...base,
pageSize: DEFAULT_PAGE_SIZE,
page: nextPage,
};
}
const result = await postRepository.getPosts(queryParams); const result = await postRepository.getPosts(queryParams);
// 合并新数据 // 合并新数据
const newPosts = [...state.posts, ...result.posts]; const newPosts = [...state.posts, ...result.posts];
// 判断响应是否为 cursor 模式(有 next_cursor 返回)
const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
// 更新状态 // 更新状态
this.updatePostsState(key, { this.updatePostsState(key, {
posts: newPosts, posts: newPosts,
hasMore: result.hasMore, hasMore: result.hasMore,
cursor: result.nextCursor || null, cursor: responseIsCursorMode ? result.nextCursor : null,
currentPage: useCursorMode ? state.currentPage : ((state.currentPage || 1) + 1),
isLoading: false, isLoading: false,
lastLoadTime: Date.now(), lastLoadTime: Date.now(),
}); });
@@ -398,6 +439,7 @@ class ProcessPostUseCase {
}; };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败'; const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败';
console.error('[ProcessPostUseCase] Load more error:', errorMessage);
this.updatePostsState(key, { this.updatePostsState(key, {
isLoading: false, isLoading: false,
error: errorMessage, error: errorMessage,

View File

@@ -231,9 +231,12 @@ export class PostRepository implements IPostRepository {
try { try {
const queryParams: Record<string, any> = {}; const queryParams: Record<string, any> = {};
if (params?.page) queryParams.page = params.page; if (params?.page !== undefined) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize; if (params?.pageSize) queryParams.page_size = params.pageSize;
if (params?.cursor) queryParams.cursor = params.cursor; // 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式
if (params?.cursor !== undefined && params?.cursor !== null) {
queryParams.cursor = params.cursor;
}
if (params?.post_type) queryParams.tab = params.post_type; if (params?.post_type) queryParams.tab = params.post_type;
if (params?.communityId) queryParams.community_id = params.communityId; if (params?.communityId) queryParams.community_id = params.communityId;
if (params?.authorId) queryParams.author_id = params.authorId; if (params?.authorId) queryParams.author_id = params.authorId;
@@ -253,9 +256,16 @@ export class PostRepository implements IPostRepository {
this.saveToLocalCache(post); this.saveToLocalCache(post);
}); });
// 计算 hasMore优先使用 has_more否则通过 page/total_pages 计算
let hasMore = response.has_more;
if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) {
hasMore = response.page < response.total_pages;
}
hasMore = hasMore ?? false;
return { return {
posts, posts,
hasMore: response.has_more ?? false, hasMore,
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
total: response.total, total: response.total,
}; };

View File

@@ -29,7 +29,7 @@ export function useCursorPagination<T, P = void>(
const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
const [state, setState] = useState<CursorPaginationState<T>>({ const [state, setState] = useState<CursorPaginationState<T>>({
items: [], list: [],
nextCursor: null, nextCursor: null,
prevCursor: null, prevCursor: null,
hasMore: true, // 初始设为 true以便首次加载可以执行 hasMore: true, // 初始设为 true以便首次加载可以执行
@@ -86,7 +86,7 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
items: [...prev.items, ...response.items], list: [...prev.list, ...response.list],
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
@@ -134,7 +134,7 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
// 向前加载时,新数据放在前面 // 向前加载时,新数据放在前面
items: [...response.items, ...prev.items], list: [...response.list, ...prev.list],
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more, hasMore: response.has_more,
@@ -169,7 +169,7 @@ export function useCursorPagination<T, P = void>(
if (cancelledRef.current) return; if (cancelledRef.current) return;
setState({ setState({
items: response.items, list: response.list,
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
@@ -196,7 +196,7 @@ export function useCursorPagination<T, P = void>(
isFirstLoadRef.current = true; isFirstLoadRef.current = true;
isLoadingRef.current = false; isLoadingRef.current = false;
setState({ setState({
items: [], list: [],
nextCursor: null, nextCursor: null,
prevCursor: null, prevCursor: null,
hasMore: true, // 重置后也设为 true以便可以重新加载 hasMore: true, // 重置后也设为 true以便可以重新加载
@@ -208,16 +208,16 @@ export function useCursorPagination<T, P = void>(
}, []); }, []);
// 设置数据(用于外部数据注入) // 设置数据(用于外部数据注入)
const setItems = useCallback( const setList = useCallback(
( (
items: T[], list: T[],
nextCursor: string | null, nextCursor: string | null,
prevCursor: string | null, prevCursor: string | null,
hasMore: boolean hasMore: boolean
) => { ) => {
setState(prev => ({ setState(prev => ({
...prev, ...prev,
items, list,
nextCursor, nextCursor,
prevCursor, prevCursor,
hasMore, hasMore,
@@ -230,7 +230,7 @@ export function useCursorPagination<T, P = void>(
// 自动加载第一页 // 自动加载第一页
useEffect(() => { useEffect(() => {
// 使用 ref 防止重复加载,不依赖 loadMore 函数引用 // 使用 ref 防止重复加载,不依赖 loadMore 函数引用
// 只检查 autoLoad 和 autoLoadRef不再依赖 state.items.length // 只检查 autoLoad 和 autoLoadRef不再依赖 state.list.length
if (!autoLoad || autoLoadRef.current) return; if (!autoLoad || autoLoadRef.current) return;
autoLoadRef.current = true; autoLoadRef.current = true;
@@ -261,7 +261,7 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
items: response.items, list: response.list,
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
@@ -296,7 +296,7 @@ export function useCursorPagination<T, P = void>(
loadPrevious, loadPrevious,
refresh, refresh,
reset, reset,
setItems, setList,
}; };
} }

View File

@@ -4,13 +4,12 @@
* 集成 ProcessPostUseCase 进行状态管理 * 集成 ProcessPostUseCase 进行状态管理
*/ */
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { import {
PostIdentifier, PostIdentifier,
PostUpdate, PostUpdate,
PostUpdateType, PostUpdateType,
PostDiffConfig, PostDiffConfig,
PostDiffResult,
} from '../infrastructure/diff/postTypes'; } from '../infrastructure/diff/postTypes';
import { import {
PostDiffCalculator, PostDiffCalculator,
@@ -119,7 +118,6 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
enableDiff = true, enableDiff = true,
enableBatching = true, enableBatching = true,
maxPostCount = 10000, maxPostCount = 10000,
changeRatioThreshold = 0.5,
listKey = 'default', listKey = 'default',
autoSubscribe = true, autoSubscribe = true,
} = options; } = options;
@@ -147,37 +145,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
const previousPostsRef = useRef<T[]>(initialPosts); const previousPostsRef = useRef<T[]>(initialPosts);
const unsubscribeRef = useRef<(() => void) | null>(null); const unsubscribeRef = useRef<(() => void) | null>(null);
// ==================== 初始化 ==================== // ==================== 批量更新处理(须在订阅 batcher 的 useEffect 之前定义)====================
// 初始化差异计算器
useEffect(() => {
if (enableDiff) {
calculatorRef.current = createPostDiffCalculator<T>(diffConfig);
}
return () => {
calculatorRef.current = null;
};
}, [enableDiff, diffConfig]);
// 初始化批量处理器
useEffect(() => {
if (enableBatching) {
batcherRef.current = createPostUpdateBatcher(batcherOptions);
// 订阅批量更新
const unsubscribe = batcherRef.current.subscribe((updates) => {
handleBatchUpdates(updates);
});
return () => {
unsubscribe();
batcherRef.current?.destroy();
batcherRef.current = null;
};
}
}, [enableBatching, batcherOptions]);
// ==================== 批量更新处理 ====================
/** /**
* 处理批量更新 * 处理批量更新
@@ -289,123 +257,34 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
} }
}, []); }, []);
// ==================== 差异计算 ==================== // ==================== 初始化 ====================
/** // 初始化差异计算器
* 计算并应用差异 useEffect(() => {
*/ if (enableDiff) {
const calculateAndApplyDiff = useCallback((newPosts: T[]) => { calculatorRef.current = createPostDiffCalculator<T>(diffConfig);
const previousPosts = previousPostsRef.current;
// 如果帖子数量变化太大,直接重置
const previousCount = previousPosts.length;
const currentCount = newPosts.length;
const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount;
if (changeRatio > changeRatioThreshold) {
setPosts(newPosts);
previousPostsRef.current = newPosts;
return;
} }
return () => {
calculatorRef.current = null;
};
}, [enableDiff, diffConfig]);
// 使用差异计算 // 初始化批量处理器
if (enableDiff && calculatorRef.current) { useEffect(() => {
const diff = calculatorRef.current.calculateDiff(previousPosts, newPosts); if (!enableBatching) return;
if (diff.shouldReset) { batcherRef.current = createPostUpdateBatcher(batcherOptions);
setPosts(newPosts);
} else {
// 将差异转换为更新操作
const updates: PostUpdate[] = [];
// 处理新增 const unsubscribe = batcherRef.current.subscribe((updates) => {
if (diff.added.length > 0) {
if (diff.added.length === 1) {
updates.push({
type: PostUpdateType.ADD,
post: diff.added[0],
timestamp: Date.now(),
});
} else {
updates.push({
type: PostUpdateType.BATCH_ADD,
posts: diff.added,
timestamp: Date.now(),
});
}
}
// 处理更新
if (diff.updated.length > 0) {
if (diff.updated.length === 1) {
updates.push({
type: PostUpdateType.UPDATE,
postId: diff.updated[0].post.id,
updates: diff.updated[0].changes,
timestamp: Date.now(),
});
} else {
updates.push({
type: PostUpdateType.BATCH_UPDATE,
updates: diff.updated.map(u => ({
postId: u.post.id,
changes: u.changes,
})),
timestamp: Date.now(),
});
}
}
// 处理删除
if (diff.deleted.length > 0) {
if (diff.deleted.length === 1) {
updates.push({
type: PostUpdateType.DELETE,
postId: diff.deleted[0].post.id,
timestamp: Date.now(),
});
} else {
updates.push({
type: PostUpdateType.BATCH_DELETE,
postIds: diff.deleted.map(d => d.post.id),
timestamp: Date.now(),
});
}
}
// 处理移动
if (diff.moved.length > 0) {
for (const move of diff.moved) {
updates.push({
type: PostUpdateType.MOVE,
postId: move.post.id,
toIndex: move.toIndex,
timestamp: Date.now(),
});
}
}
// 应用更新
if (updates.length > 0) {
if (enableBatching && batcherRef.current) {
batcherRef.current.addUpdates(updates);
} else {
handleBatchUpdates(updates); handleBatchUpdates(updates);
} });
}
}
} else {
// 不使用差异计算,直接设置
setPosts(newPosts);
}
// 更新帖子数量限制检查 return () => {
if (newPosts.length > maxPostCount) { unsubscribe();
console.warn(`[useDifferentialPosts] Post count (${newPosts.length}) exceeds limit (${maxPostCount})`); batcherRef.current?.destroy();
} batcherRef.current = null;
};
previousPostsRef.current = newPosts; }, [enableBatching, batcherOptions, handleBatchUpdates]);
}, [enableDiff, enableBatching, maxPostCount, changeRatioThreshold, handleBatchUpdates]);
// ==================== UseCase 订阅 ==================== // ==================== UseCase 订阅 ====================
@@ -415,26 +294,39 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => { const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => {
switch (event.type) { switch (event.type) {
case 'state_changed': { case 'state_changed': {
const { state } = event.payload as { key: string; state: PostsState }; const { key: eventKey, state } = event.payload as { key: string; state: PostsState };
// 全局订阅:只应用当前列表 key避免其他 Tab/列表的 state 覆盖本列表(如 createPost 会更新所有 key
if (eventKey !== listKey) {
break;
}
if (state) { if (state) {
setLoading(state.isLoading); setLoading(state.isLoading);
setRefreshing(state.isRefreshing); setRefreshing(state.isRefreshing);
setError(state.error); setError(state.error);
setHasMore(state.hasMore); setHasMore(state.hasMore);
// 如果帖子列表有变化,进行差异计算 // ProcessPostUseCase 的 state.posts 已是合并后的完整列表。
// 不再走差异计算 + PostUpdateBatcher批量应用晚于 previousPostsRef 更新时,
// BATCH_ADD 会在过短的 currentPosts 上 splice导致连续游标加载「请求成功但列表不增长」。
batcherRef.current?.clearPending();
calculatorRef.current?.reset();
if (state.posts && state.posts.length > 0) { if (state.posts && state.posts.length > 0) {
calculateAndApplyDiff(state.posts as unknown as T[]); const next = state.posts as unknown as T[];
if (next.length > maxPostCount) {
console.warn(`[useDifferentialPosts] Post count (${next.length}) exceeds limit (${maxPostCount})`);
}
setPosts(next);
previousPostsRef.current = next;
} else {
setPosts([]);
previousPostsRef.current = [];
} }
} }
break; break;
} }
case 'posts_loaded': { case 'posts_loaded': {
const { posts: loadedPosts } = event.payload; // fetchPosts 在 updatePostsState 时已发出 state_changed含合并后 posts此处不再应用避免与其它 key 竞态或重复计算
if (loadedPosts) {
calculateAndApplyDiff(loadedPosts as T[]);
}
break; break;
} }
@@ -485,12 +377,17 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
} }
case 'error_occurred': { case 'error_occurred': {
const { error: errorMsg } = event.payload; const payload = event.payload as { key?: string; error?: string };
setError(errorMsg); if (payload.key !== undefined && payload.key !== listKey) {
break;
}
if (payload.error !== undefined) {
setError(payload.error);
}
break; break;
} }
} }
}, [calculateAndApplyDiff]); }, [listKey, maxPostCount]);
// 订阅 UseCase 状态变化 // 订阅 UseCase 状态变化
useEffect(() => { useEffect(() => {

View File

@@ -205,7 +205,7 @@ export interface CursorPaginationConfig {
*/ */
export interface CursorPaginationState<T> { export interface CursorPaginationState<T> {
/** 数据项列表 */ /** 数据项列表 */
items: T[]; list: T[];
/** 下一页游标 */ /** 下一页游标 */
nextCursor: string | null; nextCursor: string | null;
/** 上一页游标 */ /** 上一页游标 */
@@ -235,7 +235,7 @@ export interface CursorPaginationActions<T> {
/** 重置状态 */ /** 重置状态 */
reset: () => void; reset: () => void;
/** 设置数据(用于外部数据注入) */ /** 设置数据(用于外部数据注入) */
setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; setList: (list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void;
} }
/** /**
@@ -253,7 +253,7 @@ export type CursorFetchFunction<T, P = void> = (params: {
/** 额外参数 */ /** 额外参数 */
extraParams?: P; extraParams?: P;
}) => Promise<{ }) => Promise<{
items: T[]; list: T[];
next_cursor: string | null; next_cursor: string | null;
prev_cursor: string | null; prev_cursor: string | null;
has_more: boolean; has_more: boolean;

View File

@@ -89,6 +89,10 @@ export const HomeScreen: React.FC = () => {
const postIdsRef = useRef<Set<string>>(new Set()); const postIdsRef = useRef<Set<string>>(new Set());
const lastSwipeAtRef = useRef(0); const lastSwipeAtRef = useRef(0);
// 网格模式滚动位置检测
const gridScrollYRef = useRef(0);
const isLoadingMoreRef = useRef(false);
// 构建一个以 postId 为 key 的 map用于快速查找 // 构建一个以 postId 为 key 的 map用于快速查找
const postsMap = useMemo(() => { const postsMap = useMemo(() => {
const map = new Map<string, Post>(); const map = new Map<string, Post>();
@@ -131,15 +135,31 @@ export const HomeScreen: React.FC = () => {
// 加载更多方法 // 加载更多方法
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (hasMore && !isLoading) { if (hasMore && !isLoading && !isLoadingMoreRef.current) {
isLoadingMoreRef.current = true;
try { try {
await processPostUseCase.loadMorePosts(listKey); await processPostUseCase.loadMorePosts(listKey);
} catch (err) { } catch (err) {
console.error('加载更多失败:', err); console.error('加载更多失败:', err);
} finally {
isLoadingMoreRef.current = false;
} }
} }
}, [listKey, hasMore, isLoading]); }, [listKey, hasMore, isLoading]);
// 网格模式滚动处理 - 检测是否滚动到底部
const handleGridScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const scrollY = contentOffset.y;
const visibleHeight = layoutMeasurement.height;
const contentHeight = contentSize.height;
// 当滚动到距离底部 200px 时触发加载更多
if (scrollY + visibleHeight >= contentHeight - 200) {
loadMore();
}
}, [loadMore]);
// 刷新方法 - 先获取正确类型的帖子,再刷新 // 刷新方法 - 先获取正确类型的帖子,再刷新
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -485,6 +505,7 @@ export const HomeScreen: React.FC = () => {
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
scrollEventThrottle={100} scrollEventThrottle={100}
onScroll={handleGridScroll}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isRefreshing} refreshing={isRefreshing}
@@ -495,6 +516,12 @@ export const HomeScreen: React.FC = () => {
} }
> >
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
{/* 加载更多指示器 */}
{isLoading && displayPosts.length > 0 && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView> </ScrollView>
); );
} }
@@ -767,4 +794,9 @@ const styles = StyleSheet.create({
height: 72, height: 72,
borderRadius: 36, borderRadius: 36,
}, },
loadingMore: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
}); });

View File

@@ -72,7 +72,7 @@ export const PostDetailScreen: React.FC = () => {
// 使用游标分页 Hook 管理评论列表 // 使用游标分页 Hook 管理评论列表
const { const {
items: paginatedComments, list: paginatedComments,
isLoading: isCommentsLoading, isLoading: isCommentsLoading,
isRefreshing: isCommentsRefreshing, isRefreshing: isCommentsRefreshing,
hasMore: hasMoreComments, hasMore: hasMoreComments,
@@ -206,13 +206,13 @@ export const PostDetailScreen: React.FC = () => {
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
useEffect(() => { useEffect(() => {
if (shouldScrollToComments && !loading && comments.length > 0) { if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) {
// 延迟确保列表已完成渲染和布局 // 延迟确保列表已完成渲染和布局
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 });
}, 500); }, 500);
} }
}, [shouldScrollToComments, loading, comments.length]); }, [shouldScrollToComments, loading, comments?.length]);
// 动态设置导航头部 // 动态设置导航头部
useEffect(() => { useEffect(() => {
@@ -1413,7 +1413,7 @@ export const PostDetailScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : comments.length > 0 ? ( ) : (comments?.length ?? 0) > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}> <Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
</Text> </Text>
@@ -1546,7 +1546,7 @@ export const PostDetailScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : comments.length > 0 ? ( ) : (comments?.length ?? 0) > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}> <Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
</Text> </Text>

View File

@@ -83,7 +83,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
// 使用游标分页进行帖子搜索 // 使用游标分页进行帖子搜索
const { const {
items: searchResults, list: searchResults,
isLoading, isLoading,
isRefreshing, isRefreshing,
hasMore, hasMore,
@@ -93,7 +93,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
} = useCursorPagination<Post, { query: string }>( } = useCursorPagination<Post, { query: string }>(
async ({ cursor, pageSize, extraParams }) => { async ({ cursor, pageSize, extraParams }) => {
if (!extraParams?.query) { if (!extraParams?.query) {
return { items: [], next_cursor: null, prev_cursor: null, has_more: false }; return { list: [], next_cursor: null, prev_cursor: null, has_more: false };
} }
const response = await postService.searchPostsCursor(extraParams.query, { const response = await postService.searchPostsCursor(extraParams.query, {
cursor, cursor,

View File

@@ -72,7 +72,7 @@ const GroupMembersScreen: React.FC = () => {
// 使用游标分页 Hook 管理成员列表 // 使用游标分页 Hook 管理成员列表
const { const {
items: members, list: members,
isLoading, isLoading,
isRefreshing, isRefreshing,
hasMore, hasMore,

View File

@@ -36,7 +36,7 @@ const JoinGroupScreen: React.FC = () => {
// 使用游标分页 Hook 管理群组列表 // 使用游标分页 Hook 管理群组列表
const { const {
items: groups, list: groups,
isLoading, isLoading,
isRefreshing, isRefreshing,
hasMore, hasMore,

View File

@@ -164,7 +164,7 @@ export const MessageListScreen: React.FC = () => {
// 【游标分页】使用 useCursorPagination hook 获取会话列表 // 【游标分页】使用 useCursorPagination hook 获取会话列表
const { const {
items: conversations, list: conversationList,
isLoading, isLoading,
isRefreshing, isRefreshing,
hasMore, hasMore,
@@ -453,7 +453,7 @@ export const MessageListScreen: React.FC = () => {
if (activeSearchTab === 'chat') { if (activeSearchTab === 'chat') {
const results: SearchResultItem[] = []; const results: SearchResultItem[] = [];
for (const conv of conversations) { for (const conv of conversationList) {
await messageManager.fetchMessages(String(conv.id)); await messageManager.fetchMessages(String(conv.id));
const messages = messageManager.getMessages(String(conv.id)); const messages = messageManager.getMessages(String(conv.id));
const matchedMessages = messages.filter(msg => { const matchedMessages = messages.filter(msg => {
@@ -484,7 +484,7 @@ export const MessageListScreen: React.FC = () => {
} finally { } finally {
setIsSearching(false); setIsSearching(false);
} }
}, [conversations, activeSearchTab]); }, [conversationList, activeSearchTab]);
// 搜索文本变化时触发搜索 // 搜索文本变化时触发搜索
useEffect(() => { useEffect(() => {
@@ -533,7 +533,7 @@ export const MessageListScreen: React.FC = () => {
// 合并列表数据 // 合并列表数据
const listData: ConversationResponse[] = [ const listData: ConversationResponse[] = [
createSystemMessageItem(), createSystemMessageItem(),
...conversations, ...conversationList,
]; ];
// 总未读数 // 总未读数
@@ -923,7 +923,7 @@ export const MessageListScreen: React.FC = () => {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{isLoading && conversations.length === 0 ? ( {isLoading && conversationList.length === 0 ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>

View File

@@ -88,7 +88,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
); );
const { const {
items: messages, list: messages,
isLoading, isLoading,
isRefreshing, isRefreshing,
hasMore, hasMore,

View File

@@ -255,15 +255,18 @@ class CommentService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<Comment>> { ): Promise<CursorPaginationResponse<Comment>> {
try { try {
const response = await api.get<CursorPaginationResponse<Comment>>( const response = await api.get<any>(`/comments/post/${postId}/cursor`, { params });
`/comments/post/${postId}/cursor`, const raw = response.data;
{ params } return {
); list: Array.isArray(raw?.list) ? raw.list : [],
return response.data; next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) { } catch (error) {
console.error('获取帖子评论列表失败:', error); console.error('获取帖子评论列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -282,15 +285,18 @@ class CommentService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<Comment>> { ): Promise<CursorPaginationResponse<Comment>> {
try { try {
const response = await api.get<CursorPaginationResponse<Comment>>( const response = await api.get<any>(`/comments/${commentId}/replies/cursor`, { params });
`/comments/${commentId}/replies/cursor`, const raw = response.data;
{ params } return {
); list: Array.isArray(raw?.list) ? raw.list : [],
return response.data; next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) { } catch (error) {
console.error('获取评论回复列表失败:', error); console.error('获取评论回复列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,

View File

@@ -341,7 +341,7 @@ class GroupService {
} catch (error) { } catch (error) {
console.error('获取群组列表失败:', error); console.error('获取群组列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -368,7 +368,7 @@ class GroupService {
} catch (error) { } catch (error) {
console.error('获取群组成员列表失败:', error); console.error('获取群组成员列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -395,7 +395,7 @@ class GroupService {
} catch (error) { } catch (error) {
console.error('获取群公告列表失败:', error); console.error('获取群公告列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,

View File

@@ -573,11 +573,17 @@ class MessageService {
'/conversations/cursor', '/conversations/cursor',
{ params } { params }
); );
return response.data; const raw = response.data;
return {
list: Array.isArray(raw?.list) ? raw.list : [],
next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) { } catch (error) {
console.error('获取会话列表失败:', error); console.error('获取会话列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -596,15 +602,21 @@ class MessageService {
params: CursorPaginationRequest = {} params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<MessageResponse>> { ): Promise<CursorPaginationResponse<MessageResponse>> {
try { try {
const response = await api.get<CursorPaginationResponse<MessageResponse>>( const response = await api.get<any>(
`/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`,
{ params } { params }
); );
return response.data; const raw = response.data;
return {
list: Array.isArray(raw?.list) ? raw.list : [],
next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) { } catch (error) {
console.error('获取消息列表失败:', error); console.error('获取消息列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -631,32 +643,36 @@ class MessageService {
const data = response.data; const data = response.data;
// 兼容两种 API 响应格式 // 游标或带 page/total_pages 的分页(统一为 CursorPaginationResponselist
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
// 游标分页格式 const list = Array.isArray(data.list) ? data.list : null;
if (Array.isArray(data.items)) { if (list) {
return { const isPageShape =
items: data.items, typeof data.page === 'number' &&
next_cursor: data.next_cursor ?? null, typeof data.total_pages === 'number' &&
prev_cursor: data.prev_cursor ?? null, data.next_cursor === undefined &&
has_more: data.has_more ?? false, data.prev_cursor === undefined;
}; if (isPageShape) {
}
// 传统分页格式 - 转换为游标格式
if (Array.isArray(data.list)) {
const currentPage = data.page || 1; const currentPage = data.page || 1;
const totalPages = data.total_pages || 1; const totalPages = data.total_pages || 1;
return { return {
items: data.list, list,
next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null,
prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null,
has_more: currentPage < totalPages, has_more: currentPage < totalPages,
}; };
} }
return {
list,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
}
} }
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -664,7 +680,7 @@ class MessageService {
} catch (error) { } catch (error) {
console.error('获取系统消息列表失败:', error); console.error('获取系统消息列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,

View File

@@ -172,7 +172,7 @@ class NotificationService {
} catch (error) { } catch (error) {
console.error('获取通知列表失败:', error); console.error('获取通知列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,

View File

@@ -305,36 +305,32 @@ class PostService {
const data = response.data; const data = response.data;
// 兼容两种 API 响应格式: if (data && typeof data === 'object' && Array.isArray(data.list)) {
// 1. 游标分页格式:{ items, next_cursor, prev_cursor, has_more } const isPageShape =
// 2. 传统分页格式:{ list, total, page, page_size, total_pages } typeof data.page === 'number' &&
if (data && typeof data === 'object') { typeof data.total_pages === 'number' &&
// 游标分页格式 data.next_cursor === undefined &&
if (Array.isArray(data.items)) { data.prev_cursor === undefined;
return { if (isPageShape) {
items: data.items,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
}
// 传统分页格式 - 转换为游标格式
if (Array.isArray(data.list)) {
const currentPage = data.page || 1; const currentPage = data.page || 1;
const totalPages = data.total_pages || 1; const totalPages = data.total_pages || 1;
return { return {
items: data.list, list: data.list,
// 使用页码作为游标
next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null,
prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null,
has_more: currentPage < totalPages, has_more: currentPage < totalPages,
}; };
} }
return {
list: data.list,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
} }
// 默认返回空数据
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -342,7 +338,7 @@ class PostService {
} catch (error) { } catch (error) {
console.error('获取帖子列表失败:', error); console.error('获取帖子列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -368,30 +364,32 @@ class PostService {
const data = response.data; const data = response.data;
// 兼容两种 API 响应格式 if (data && typeof data === 'object' && Array.isArray(data.list)) {
if (data && typeof data === 'object') { const isPageShape =
if (Array.isArray(data.items)) { typeof data.page === 'number' &&
return { typeof data.total_pages === 'number' &&
items: data.items, data.next_cursor === undefined &&
next_cursor: data.next_cursor ?? null, data.prev_cursor === undefined;
prev_cursor: data.prev_cursor ?? null, if (isPageShape) {
has_more: data.has_more ?? false,
};
}
if (Array.isArray(data.list)) {
const currentPage = data.page || 1; const currentPage = data.page || 1;
const totalPages = data.total_pages || 1; const totalPages = data.total_pages || 1;
return { return {
items: data.list, list: data.list,
next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null,
prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null,
has_more: currentPage < totalPages, has_more: currentPage < totalPages,
}; };
} }
return {
list: data.list,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
} }
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -399,7 +397,7 @@ class PostService {
} catch (error) { } catch (error) {
console.error('搜索帖子失败:', error); console.error('搜索帖子失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -425,30 +423,32 @@ class PostService {
const data = response.data; const data = response.data;
// 兼容两种 API 响应格式 if (data && typeof data === 'object' && Array.isArray(data.list)) {
if (data && typeof data === 'object') { const isPageShape =
if (Array.isArray(data.items)) { typeof data.page === 'number' &&
return { typeof data.total_pages === 'number' &&
items: data.items, data.next_cursor === undefined &&
next_cursor: data.next_cursor ?? null, data.prev_cursor === undefined;
prev_cursor: data.prev_cursor ?? null, if (isPageShape) {
has_more: data.has_more ?? false,
};
}
if (Array.isArray(data.list)) {
const currentPage = data.page || 1; const currentPage = data.page || 1;
const totalPages = data.total_pages || 1; const totalPages = data.total_pages || 1;
return { return {
items: data.list, list: data.list,
next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null,
prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null,
has_more: currentPage < totalPages, has_more: currentPage < totalPages,
}; };
} }
return {
list: data.list,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
} }
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,
@@ -456,7 +456,7 @@ class PostService {
} catch (error) { } catch (error) {
console.error('获取用户帖子列表失败:', error); console.error('获取用户帖子列表失败:', error);
return { return {
items: [], list: [],
next_cursor: null, next_cursor: null,
prev_cursor: null, prev_cursor: null,
has_more: false, has_more: false,

View File

@@ -489,7 +489,7 @@ export interface CursorPaginationRequest {
*/ */
export interface CursorPaginationResponse<T> { export interface CursorPaginationResponse<T> {
/** 数据项列表 */ /** 数据项列表 */
items: T[]; list: T[];
/** 下一页游标 */ /** 下一页游标 */
next_cursor: string | null; next_cursor: string | null;
/** 上一页游标 */ /** 上一页游标 */