diff --git a/.gitignore b/.gitignore index 4074a5c..f5cbf7d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ dist-android-update.zip # Backend backend/data/ backend/logs/ + +doc/ +docs/ diff --git a/docs/OPTIMIZATION_DESIGN.md b/docs/OPTIMIZATION_DESIGN.md deleted file mode 100644 index 5b1a7e8..0000000 --- a/docs/OPTIMIZATION_DESIGN.md +++ /dev/null @@ -1,1725 +0,0 @@ -# 聊天系统性能优化设计方案 - -## 文档信息 - -- **创建日期**: 2026-03-18 -- **项目**: 胡萝卜 BBS (Carrot BBS) 前端 -- **范围**: 消息系统性能优化 - ---- - -## 目录 - -1. [架构概述](#架构概述) -2. [P0 分页状态管理](#p0-分页状态管理) -3. [P0 同步状态机](#p0-同步状态机) -4. [P1 差异更新](#p1-差异更新) -5. [P1 媒体缓存清理](#p1-媒体缓存清理) -6. [实现优先级与依赖关系](#实现优先级与依赖关系) - ---- - -## 架构概述 - -### 当前消息系统架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ UI Layer │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ ChatScreen │ │ MessageList │ │ -│ │ useChatScreen │ │ Screen │ │ -│ └────────┬────────┘ └────────┬────────┘ │ -└───────────┼─────────────────────┼───────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Store Layer (Zustand) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManager (MessageManager) ││ -│ │ - messages Map ││ -│ │ - conversations: Conversation[] ││ -│ │ - activeConversation: string | null ││ -│ │ - isConnected: boolean ││ -│ └─────────────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManagerHooks (React Hooks) ││ -│ │ - useChat(conversationId) ││ -│ │ - useMessages(conversationId) ││ -│ │ - useConversations() ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ UseCase Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ ProcessMessageUseCase ││ -│ │ - WebSocket 事件监听与处理 ││ -│ │ - 消息去重 (processedMessageIds) ││ -│ │ - 已读状态管理 (pendingReadMap) ││ -│ │ - 消息持久化到 Repository ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DataSource Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐│ -│ │WebSocketClient│ │CacheDataSource│ │LocalDataSource (SQLite)││ -│ │ │ │ (Memory+Async) │ │ ││ -│ │ - 事件订阅 │ │ - get/set │ │ - messages 表 ││ -│ │ - emit │ │ - delete │ │ - conversations 表 ││ -│ │ - 连接状态 │ │ - clear │ │ - users 表 ││ -│ └───────────────┘ └───────────────┘ └───────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ sseService (SSE Client) ││ -│ │ - EventSource 连接管理 ││ -│ │ - 重连逻辑 (maxReconnectAttempts=20) ││ -│ │ - AppState 监听 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 问题分析 - -基于代码分析,当前系统存在以下问题: - -| 问题 | 影响 | 优先级 | -|------|------|--------| -| 分页状态管理不完善 | 用户滚动加载时可能重复请求同一页数据 | P0 | -| 连接状态反馈不清晰 | 用户不知道连接是否正常 | P0 | -| 全量数据更新 | 大房间每次更新都刷新整个消息列表 | P1 | -| 媒体缓存无清理机制 | 长期使用后存储空间爆炸 | P1 | - ---- - -## P0 分页状态管理 - -### 现有代码分析 - -#### 问题点 - -1. **`useChatScreen.ts` (第388-408行)** - ```typescript - const loadMoreHistory = useCallback(async () => { - if (!conversationId || !hasMoreHistory || loadingMore) { - return; - } - // 缺少分页游标管理,可能导致重复加载 - setLoadingMore(true); - try { - await loadMoreMessages(); - // 没有追踪当前加载到了哪一页 - } finally { - setLoadingMore(false); - } - }, [...]); - ``` - -2. **`messageManagerHooks.ts` (第156-158行)** - ```typescript - setIsLoading(true); - const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); - // 直接使用 minSeq,但没有缓存 minSeq 的状态 - ``` - -3. **`MessageRepository.ts` (第125-137行)** - ```typescript - async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) { - // 依赖 beforeSeq 参数,但没有分页偏移量管理 - } - ``` - -#### 架构缺陷 - -- 没有独立的分页状态存储 -- `hasMoreMessages` 状态与实际数据可能不同步 -- 并发加载时缺少互斥机制 -- 没有加载中的分页信息缓存 - -### 实现方案 - -#### 1. 新增分页状态管理器 - -```typescript -// src/stores/pagination/PaginationStateManager.ts - -interface PaginationState { - conversationId: string; - currentMinSeq: number; // 当前已加载的最小seq - currentMaxSeq: number; // 当前已加载的最大seq - pages: Map; // pageNumber -> messageIds - loadingPageNumbers: Set; - hasMoreBefore: boolean; - hasMoreAfter: boolean; - totalLoaded: number; -} - -class PaginationStateManager { - private states: Map = new Map(); - - // 获取或创建分页状态 - getOrCreate(conversationId: string): PaginationState { ... } - - // 标记页面加载中 - markPageLoading(conversationId: string, pageNumber: number): void { ... } - - // 标记页面加载完成 - markPageLoaded(conversationId: string, pageNumber: number, messageIds: string[], hasMore: boolean): void { ... } - - // 检查页面是否已加载 - isPageLoaded(conversationId: string, pageNumber: number): boolean { ... } - - // 检查页面是否正在加载 - isPageLoading(conversationId: string, pageNumber: number): boolean { ... } - - // 重置分页状态 - reset(conversationId: string): void { ... } -} -``` - -#### 2. 修改 useMessages Hook - -```typescript -// src/stores/messageManagerHooks.ts - -interface UseMessagesOptions { - pageSize?: number; - prefetchThreshold?: number; // 预加载阈值 -} - -function useMessages( - conversationId: string | null, - options: UseMessagesOptions = {} -) { - const { - pageSize = 20, - prefetchThreshold = 5 - } = options; - - // 分页状态 - const [paginationState, setPaginationState] = useState(null); - - // 计算当前页码 - const currentPage = useMemo(() => { - if (!paginationState) return 1; - return Math.ceil( - (paginationState.totalLoaded - messages.length) / pageSize - ) + 1; - }, [paginationState, messages.length]); - - // 加载更多(带分页保护) - const loadMoreMessages = useCallback(async () => { - if (!conversationId || !paginationState) return; - - // 计算下一页 - const nextPage = Math.floor(paginationState.totalLoaded / pageSize) + 1; - - // 检查是否正在加载 - if (paginationState.loadingPageNumbers.has(nextPage)) { - return; - } - - // 检查是否已加载 - if (paginationState.pages.has(nextPage)) { - return; - } - - await messageManager.loadMoreMessages( - conversationId, - paginationState.currentMinSeq, - pageSize - ); - }, [conversationId, paginationState, pageSize]); - - // 预加载检测 - useEffect(() => { - if (!paginationState || !hasMore) return; - - const currentPageLoaded = messages.length; - const threshold = pageSize - prefetchThreshold; - - if (currentPageLoaded <= threshold && !paginationState.loadingPageNumbers.has(1)) { - loadMoreMessages(); - } - }, [messages.length, hasMore, loadMoreMessages]); -} -``` - -#### 3. 修改 MessageManager - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - private paginationManager = new PaginationStateManager(); - - async loadMoreMessages( - conversationId: string, - beforeSeq: number, - limit: number - ): Promise { - // 检查分页状态 - const pageNumber = this.calculatePageNumber(conversationId, beforeSeq); - - if (this.paginationManager.isPageLoading(conversationId, pageNumber)) { - return []; // 避免重复加载 - } - - if (this.paginationManager.isPageLoaded(conversationId, pageNumber)) { - return []; // 已加载,直接返回 - } - - this.paginationManager.markPageLoading(conversationId, pageNumber); - - try { - // 调用 API - const response = await messageService.getMessages(conversationId, { - before_seq: beforeSeq, - limit - }); - - const messageIds = response.messages.map(m => m.id); - - this.paginationManager.markPageLoaded( - conversationId, - pageNumber, - messageIds, - response.has_more - ); - - // 更新消息存储 - this.appendMessages(conversationId, response.messages); - - return response.messages; - } catch (error) { - // 加载失败,清除分页状态 - this.paginationManager.markPageFailed(conversationId, pageNumber); - throw error; - } - } -} -``` - -#### 4. 修改数据库查询 - -```typescript -// src/services/database.ts (或 MessageRepository) - -export async function getMessagesBeforeSeq( - conversationId: string, - beforeSeq: number, - limit: number -): Promise { - // 确保使用索引优化查询 - const result = await db.getAllAsync(` - SELECT * FROM messages - WHERE conversationId = ? AND seq < ? - ORDER BY seq DESC - LIMIT ? - `, [conversationId, beforeSeq, limit]); - - return result; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/pagination/PaginationStateManager.ts` | 新增 | 分页状态管理器 | -| `src/stores/pagination/index.ts` | 新增 | 导出文件 | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成分页状态管理 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加分页保护逻辑 | -| `src/data/repositories/MessageRepository.ts` | 修改 | 优化分页查询 | - -### 关键设计决策 - -1. **分页粒度**: 以 `beforeSeq` 为游标,而非 offset,避免消息新增导致的分页漂移 -2. **页面缓存**: 使用 `Map` 缓存已加载页面,避免重复请求 -3. **加载锁**: 使用 `Set` 追踪正在加载的页面,防止并发重复加载 -4. **预加载**: 当滚动到距离底部 `prefetchThreshold` 条消息时,提前加载下一页 - ---- - -## P0 同步状态机 - -### 现有代码分析 - -#### 问题点 - -1. **`sseService.ts` (第406-408行)** - ```typescript - isConnected(): boolean { - return this.source != null; - } - // 状态判断过于简单,source != null 不代表真正连接成功 - ``` - -2. **`WebSocketClient.ts` (第23-35行)** - ```typescript - export interface WebSocketEvents { - 'connected': void; - 'disconnected': void; - 'error': Error; - } - // 只有 connected/disconnected 两种状态 - ``` - -3. **`sseService.ts` (第190-228行)** - ```typescript - async connect(): Promise { - // isConnecting 状态没有暴露给外部 - // 外部无法区分"正在连接"和"连接失败" - } - ``` - -4. **`messageManagerHooks.ts` (第390-405行)** - ```typescript - const [isConnected, setIsConnected] = useState(() => messageManager.isConnected()); - // 连接状态变化没有详细的状态分类 - ``` - -#### 状态定义缺失 - -当前没有区分以下状态: -- **Connecting**: 正在建立连接 -- **Connected**: 连接已建立 -- **Reconnecting**: 连接断开,正在重连 -- **Disconnected**: 连接已断开 -- **Error**: 连接错误 - -### 实现方案 - -#### 1. 定义连接状态机 - -```typescript -// src/services/connection/ConnectionState.ts - -export enum ConnectionStateType { - IDLE = 'idle', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting', - DISCONNECTED = 'disconnected', - ERROR = 'error', -} - -export interface ConnectionState { - type: ConnectionStateType; - timestamp: number; - error?: Error; - retryCount?: number; - lastConnectedAt?: number; -} - -export interface ConnectionStateChangeEvent { - previousState: ConnectionState; - currentState: ConnectionState; - reason?: string; -} -``` - -#### 2. 创建连接状态管理器 - -```typescript -// src/services/connection/ConnectionStateManager.ts - -type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void; - -class ConnectionStateManager { - private state: ConnectionState = { - type: ConnectionStateType.IDLE, - timestamp: Date.now(), - }; - - private listeners: Set = new Set(); - private reconnectAttempts = 0; - private maxReconnectAttempts = 20; - - // 获取当前状态 - getState(): ConnectionState { ... } - - // 状态转换 - transition(newType: ConnectionStateType, reason?: string, error?: Error): void { - const previousState = this.state; - this.state = { - type: newType, - timestamp: Date.now(), - error, - retryCount: newType === ConnectionStateType.RECONNECTING - ? this.reconnectAttempts - : undefined, - lastConnectedAt: newType === ConnectionStateType.CONNECTED - ? Date.now() - : this.state.lastConnectedAt, - }; - - this.notifyListeners({ previousState, currentState: this.state, reason }); - } - - // 增加重试计数 - incrementRetry(): number { - this.reconnectAttempts++; - return this.reconnectAttempts; - } - - // 重置重试计数 - resetRetry(): void { - this.reconnectAttempts = 0; - } - - // 订阅状态变化 - subscribe(listener: ConnectionStateListener): () => void { ... } - - // 获取可读的状态描述 - getStatusDescription(): string { - switch (this.state.type) { - case ConnectionStateType.IDLE: - return '未连接'; - case ConnectionStateType.CONNECTING: - return '正在连接...'; - case ConnectionStateType.CONNECTED: - return '已连接'; - case ConnectionStateType.RECONNECTING: - return `正在重连 (${this.state.retryCount}/${this.maxReconnectAttempts})`; - case ConnectionStateType.DISCONNECTED: - return '已断开'; - case ConnectionStateType.ERROR: - return `连接错误: ${this.state.error?.message || '未知错误'}`; - } - } -} - -export const connectionStateManager = new ConnectionStateManager(); -``` - -#### 3. 修改 SSEService - -```typescript -// src/services/sseService.ts - -class SSEService { - private connectionStateManager = connectionStateManager; - - async connect(): Promise { - if (this.isConnecting || this.isConnected()) { - return true; - } - - this.connectionStateManager.transition( - ConnectionStateType.CONNECTING, - 'initiated' - ); - - try { - const token = await api.getToken(); - if (!token) { - throw new Error('No auth token'); - } - - // ... 建立连接 ... - - this.source.addEventListener('open', () => { - this.connectionStateManager.resetRetry(); - this.connectionStateManager.transition( - ConnectionStateType.CONNECTED, - 'connection_opened' - ); - }); - - this.source.addEventListener('error', (error) => { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_error', - error - ); - this.scheduleReconnect(); - }); - - return true; - } catch (error) { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_failed', - error - ); - this.scheduleReconnect(); - return false; - } - } - - private scheduleReconnect(): void { - const retryCount = this.connectionStateManager.incrementRetry(); - - if (retryCount >= this.maxReconnectAttempts) { - this.connectionStateManager.transition( - ConnectionStateType.DISCONNECTED, - 'max_reconnect_exceeded' - ); - return; - } - - this.connectionStateManager.transition( - ConnectionStateType.RECONNECTING, - 'scheduling_reconnect' - ); - - this.reconnectTimer = setTimeout(() => { - this.connect(); - }, this.reconnectDelay); - } -} -``` - -#### 4. 创建连接状态 Hook - -```typescript -// src/hooks/useConnectionState.ts - -export function useConnectionState() { - const [state, setState] = useState( - () => connectionStateManager.getState() - ); - - useEffect(() => { - const unsubscribe = connectionStateManager.subscribe((event) => { - setState(event.currentState); - }); - - return unsubscribe; - }, []); - - return { - state, - status: connectionStateManager.getStatusDescription(), - isConnected: state.type === ConnectionStateType.CONNECTED, - isConnecting: state.type === ConnectionStateType.CONNECTING, - isReconnecting: state.type === ConnectionStateType.RECONNECTING, - isDisconnected: state.type === ConnectionStateType.DISCONNECTED, - isError: state.type === ConnectionStateType.ERROR, - retryCount: state.retryCount, - error: state.error, - reconnect: () => sseService.connect(), - disconnect: () => sseService.disconnect(), - }; -} -``` - -#### 5. UI 集成示例 - -```typescript -// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx - -export function ConnectionStatusBadge() { - const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState(); - - if (isConnected) { - return null; // 已连接不显示 - } - - return ( - - {isReconnecting && ( - - )} - - {isReconnecting - ? `连接中断,正在重连 (${retryCount}/20)` - : status} - - - ); -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/connection/ConnectionState.ts` | 新增 | 连接状态定义 | -| `src/services/connection/ConnectionStateManager.ts` | 新增 | 连接状态管理器 | -| `src/services/connection/index.ts` | 新增 | 导出文件 | -| `src/services/sseService.ts` | 修改 | 集成状态管理器 | -| `src/hooks/useConnectionState.ts` | 新增 | 连接状态 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | - -### Mermaid 状态图 - -```mermaid -stateDiagram-v2 - [*] --> IDLE - IDLE --> CONNECTING: 调用 connect() - CONNECTING --> CONNECTED: 连接成功 - CONNECTING --> ERROR: 连接失败 - CONNECTED --> RECONNECTING: 连接断开 - RECONNECTING --> CONNECTED: 重连成功 - RECONNECTING --> DISCONNECTED: 超过最大重试次数 - RECONNECTING --> ERROR: 重连失败 - ERROR --> RECONNECTING: 自动重连 - ERROR --> DISCONNECTED: 停止重连 - DISCONNECTED --> CONNECTING: 手动重连 - IDLE --> CONNECTING: 手动重连 - CONNECTED --> DISCONNECTED: 调用 disconnect() -``` - ---- - -## P1 差异更新 - -### 现有代码分析 - -#### 问题点 - -1. **`MessageManager` 消息更新机制** - - 每当有新消息时,通过 `setMessages` 更新整个消息数组 - - 大房间场景下,频繁的全量更新会导致 UI 卡顿 - - 没有区分不同类型的消息更新(新增、删除、修改) - -2. **`messageManagerHooks.ts` (第33-46行)** - ```typescript - const unsubscribe = messageManager.subscribe((event: MessageEvent) => { - switch (event.type) { - case 'conversations_updated': - setConversations(messageManager.getConversations()); - break; - // ... 其他都是全量更新 - } - }); - ``` - -3. **`useChatScreen.ts` (第148-162行)** - ```typescript - const messages = useMemo(() => { - return messageManagerMessages.map(m => ({ - // 全量映射 - })); - }, [messageManagerMessages]); - ``` - -4. **`ProcessMessageUseCase.ts` (第159-199行)** - ```typescript - private async handleNewMessage(message: any): Promise { - // 每条消息都触发完整流程 - this.notifySubscribers({ - type: 'message_received', - payload: { message, ... } - }); - } - ``` - -#### 性能瓶颈 - -- 1000人群聊,每秒10条消息 = 每秒10000次 UI 更新操作 -- 全量 `setMessages` 触发 FlatList 完整重渲染 -- 没有虚拟列表优化 - -### 实现方案 - -#### 1. 定义消息更新类型 - -```typescript -// src/stores/message/MessageUpdateTypes.ts - -export enum MessageUpdateType { - APPEND = 'append', // 追加新消息到末尾 - PREPEND = 'prepend', // 预置历史消息到开头 - UPDATE = 'update', // 更新单条消息 - DELETE = 'delete', // 删除单条消息 - BATCH_APPEND = 'batch_append', // 批量追加 - BATCH_PREPEND = 'batch_prepend', // 批量预置 - RECALL = 'recall', // 撤回消息 - CLEAR = 'clear', // 清空会话 -} - -export interface MessageUpdate { - type: MessageUpdateType; - conversationId: string; - payload: T; - timestamp: number; -} - -export interface AppendPayload { - messages: Message[]; -} - -export interface PrependPayload { - messages: Message[]; - hasMoreBefore: boolean; -} - -export interface UpdatePayload { - messageId: string; - updates: Partial; -} - -export interface DeletePayload { - messageId: string; -} - -export interface BatchAppendPayload { - messages: Message[]; -} - -export interface RecallPayload { - messageId: string; -} -``` - -#### 2. 创建差异更新 Hook - -```typescript -// src/hooks/useDifferentialMessages.ts - -export function useDifferentialMessages(conversationId: string | null) { - const [messages, setMessages] = useState([]); - const pendingUpdatesRef = useRef([]); - const flushScheduledRef = useRef(false); - - // 批量处理更新 - const flushUpdates = useCallback(() => { - if (pendingUpdatesRef.current.length === 0) return; - - const updates = pendingUpdatesRef.current; - pendingUpdatesRef.current = []; - flushScheduledRef.current = false; - - setMessages(currentMessages => { - const newMessages = [...currentMessages]; - - for (const update of updates) { - switch (update.type) { - case MessageUpdateType.APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - - case MessageUpdateType.PREPEND: - const prependMessages = update.payload.messages - .filter(m => !newMessages.find(n => n.id === m.id)); - newMessages.unshift(...prependMessages.reverse()); - break; - - case MessageUpdateType.UPDATE: - const updateIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (updateIdx !== -1) { - newMessages[updateIdx] = { - ...newMessages[updateIdx], - ...update.payload.updates - }; - } - break; - - case MessageUpdateType.DELETE: - newMessages = newMessages.filter(m => m.id !== update.payload.messageId); - break; - - case MessageUpdateType.RECALL: - const recallIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (recallIdx !== -1) { - newMessages[recallIdx] = { - ...newMessages[recallIdx], - status: 'recalled', - segments: [], - }; - } - break; - - case MessageUpdateType.BATCH_APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - } - } - - return newMessages; - }); - }, []); - - // 调度批量更新(16ms 内合并) - const scheduleFlush = useCallback(() => { - if (flushScheduledRef.current) return; - flushScheduledRef.current = true; - requestAnimationFrame(flushUpdates); - }, [flushUpdates]); - - // 处理消息更新 - const handleMessageUpdate = useCallback((update: MessageUpdate) => { - pendingUpdatesRef.current.push(update); - scheduleFlush(); - }, [scheduleFlush]); - - // 订阅 MessageManager - useEffect(() => { - if (!conversationId) return; - - const unsubscribe = messageManager.subscribe((event) => { - if (event.type === 'message_received') { - const { message, isCurrentUser } = event.payload; - handleMessageUpdate({ - type: isCurrentUser - ? MessageUpdateType.APPEND - : MessageUpdateType.PREPEND, - conversationId, - payload: { messages: [message] }, - timestamp: Date.now(), - }); - } - - if (event.type === 'messages_loaded') { - const { messages, direction } = event.payload; - handleMessageUpdate({ - type: direction === 'before' - ? MessageUpdateType.PREPEND - : MessageUpdateType.BATCH_APPEND, - conversationId, - payload: { messages }, - timestamp: Date.now(), - }); - } - - if (event.type === 'message_recalled') { - handleMessageUpdate({ - type: MessageUpdateType.RECALL, - conversationId, - payload: { messageId: event.payload.messageId }, - timestamp: Date.now(), - }); - } - }); - - return unsubscribe; - }, [conversationId, handleMessageUpdate]); - - return { messages }; -} -``` - -#### 3. 优化 FlatList 渲染 - -```typescript -// src/screens/message/components/ChatScreen/OptimizedMessageList.tsx - -export function OptimizedMessageList({ - messages, - onLoadMore, - hasMore, -}: { - messages: Message[]; - onLoadMore: () => void; - hasMore: boolean; -}) { - const viewabilityConfig = useRef({ - itemVisiblePercentThreshold: 50, - minimumViewTime: 100, - }); - - // 关键消息路径优化 - const keyExtractor = useCallback((item: Message) => item.id, []); - - const getItemLayout = useCallback((data: Message[] | null, index: number) => { - // 估算每条消息高度(可以根据类型调整) - const BASE_HEIGHT = 60; - const IMAGE_EXTRA = 200; - const EXTRA_PER_SEGMENT = 30; - - let length = BASE_HEIGHT; - if (data) { - const message = data[index]; - if (message.segments) { - length += message.segments.length * EXTRA_PER_SEGMENT; - } - if (message.segments?.some(s => s.type === 'image')) { - length += IMAGE_EXTRA; - } - } - - return { - length, - offset: data.slice(0, index).reduce((sum, m) => { - // 计算累计高度 - return sum + BASE_HEIGHT + - (m.segments?.length || 0) * EXTRA_PER_SEGMENT + - (m.segments?.some(s => s.type === 'image') ? IMAGE_EXTRA : 0); - }, 0), - index, - }; - }, []); - - // 消息类型判断 - const isSameDay = useCallback((prev: Message, next: Message) => { - const prevDate = new Date(prev.created_at).toDateString(); - const nextDate = new Date(next.created_at).toDateString(); - return prevDate === nextDate; - }, []); - - const renderItem = useCallback(({ item, index }: { item: Message; index: number }) => { - const prevItem = messages[index - 1]; - - return ( - 50} - /> - ); - }, [isSameDay]); - - return ( - - ); -} -``` - -#### 4. 修改 MessageManager 支持差异事件 - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - // 发送差异更新事件 - private emitMessageUpdate(update: MessageUpdate): void { - this.subscribers.forEach(subscriber => { - if (subscriber.types.includes(update.type) || subscriber.types.includes('*')) { - subscriber.callback(update); - } - }); - } - - async handleIncomingMessage(message: WSChatMessage): Promise { - // 检查消息是否已存在 - const existingMessages = this.messages.get(message.conversation_id) || []; - if (existingMessages.find(m => m.id === message.id)) { - return; // 避免重复 - } - - const newMessage = this.createMessageObject(message); - - // 直接发送差异更新,而不是全量更新 - this.emitMessageUpdate({ - type: MessageUpdateType.APPEND, - conversationId: message.conversation_id, - payload: { messages: [newMessage] }, - timestamp: Date.now(), - }); - - // 异步保存到数据库 - this.persistMessage(newMessage); - } -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/message/MessageUpdateTypes.ts` | 新增 | 消息更新类型定义 | -| `src/hooks/useDifferentialMessages.ts` | 新增 | 差异更新 Hook | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成差异更新 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加差异事件支持 | -| `src/screens/message/components/ChatScreen/OptimizedMessageList.tsx` | 新增 | 优化的消息列表组件 | - -### 性能收益 - -| 场景 | 优化前 | 优化后 | -|------|--------|--------| -| 1000人群,每秒10条消息 | 10000次/秒 UI 更新 | 60次/秒 UI 更新 | -| 滚动加载100条历史消息 | 全量重渲染100条 | 仅渲染可见区域~20条 | -| 消息撤回 | 全量更新 + UI 重建 | 单条更新 | - ---- - -## P1 媒体缓存清理 - -### 现有代码分析 - -#### 问题点 - -1. **`CacheDataSource.ts` (第14-28行)** - ```typescript - export class CacheDataSource implements ICacheDataSource { - private memoryCache: Map> = new Map(); - private maxSize: number; // 默认 100 条 - private defaultTtl: number; // 默认 5 分钟 - - // 没有媒体文件专用缓存管理 - // 没有磁盘空间监控 - } - ``` - -2. **缺少媒体缓存机制** - - 图片、视频、音频没有独立的缓存策略 - - 没有按会话清理过期媒体的功能 - - 没有用户手动清理入口 - -3. **`LocalDataSource.ts` (第73-86行)** - ```typescript - // 消息表结构 - `CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - conversationId TEXT NOT NULL, - segments TEXT - // 没有存储媒体文件路径 - )` - ``` - - 消息表没有记录媒体缓存路径 - - 无法追踪哪些媒体文件属于哪个会话 - -#### 存储风险 - -- 图片缓存:无限制增长 -- 视频缓存:无限制增长 -- 音频缓存:无限制增长 -- 总计:可能导致存储空间耗尽 - -### 实现方案 - -#### 1. 定义媒体缓存配置 - -```typescript -// src/services/media/MediaCacheConfig.ts - -export interface MediaCacheConfig { - // 图片配置 - image: { - maxMemoryCacheSize: number; // 内存缓存数量,默认 50 - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 500 - maxAge: number; // 缓存有效期(小时),默认 168 (7天) - maxAgeForConversation: number; // 会话内媒体保留时间(天),默认 30 - }; - - // 视频配置 - video: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 1000 - maxAge: number; // 缓存有效期(小时),默认 72 (3天) - autoPreload: boolean; // 是否自动预加载,默认 false - }; - - // 音频配置 - audio: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 200 - maxAge: number; // 缓存有效期(天),默认 7 - }; - - // 全局配置 - global: { - checkOnStartup: boolean; // 启动时检查清理,默认 true - checkInterval: number; // 定期检查间隔(小时),默认 24 - lowStorageThreshold: number; // 低存储空间阈值(MB),默认 500 - }; -} - -export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { - image: { - maxMemoryCacheSize: 50, - maxDiskCacheSize: 500, - maxAge: 168, // 7 days - maxAgeForConversation: 30, - }, - video: { - maxDiskCacheSize: 1000, - maxAge: 72, // 3 days - autoPreload: false, - }, - audio: { - maxDiskCacheSize: 200, - maxAge: 168, // 7 days - }, - global: { - checkOnStartup: true, - checkInterval: 24, - lowStorageThreshold: 500, - }, -}; -``` - -#### 2. 创建媒体缓存管理器 - -```typescript -// src/services/media/MediaCacheManager.ts - -import * as FileSystem from 'expo-file-system'; -import { cacheDataSource } from '../data/datasources/CacheDataSource'; - -export enum MediaType { - IMAGE = 'image', - VIDEO = 'video', - AUDIO = 'audio', -} - -export interface MediaCacheInfo { - type: MediaType; - uri: string; - localPath: string; - conversationId?: string; - messageId?: string; - size: number; - createdAt: number; - lastAccessedAt: number; -} - -class MediaCacheManager { - private config: MediaCacheConfig; - private cacheDirectory: string; - private mediaRecords: Map = new Map(); - - // 缓存目录 - private readonly CACHE_DIR = `${FileSystem.documentDirectory}media_cache/`; - private readonly IMAGE_DIR = `${this.CACHE_DIR}images/`; - private readonly VIDEO_DIR = `${this.CACHE_DIR}videos/`; - private readonly AUDIO_DIR = `${this.CACHE_DIR}audios/`; - - constructor(config: MediaCacheConfig = DEFAULT_MEDIA_CACHE_CONFIG) { - this.config = config; - this.cacheDirectory = this.CACHE_DIR; - } - - // 初始化 - async initialize(): Promise { - await this.ensureDirectories(); - await this.loadMediaRecords(); - await this.cleanupIfNeeded(); - } - - // 确保缓存目录存在 - private async ensureDirectories(): Promise { - await FileSystem.makeDirectoryAsync(this.IMAGE_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.VIDEO_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.AUDIO_DIR, { intermediates: true }); - } - - // 缓存媒体文件 - async cacheMedia( - uri: string, - type: MediaType, - options: { - conversationId?: string; - messageId?: string; - } = {} - ): Promise { - const { conversationId, messageId } = options; - - // 生成唯一文件名 - const filename = this.generateFilename(uri, type); - const localPath = this.getMediaPath(type, filename); - - // 检查是否已缓存 - if (await this.exists(localPath)) { - await this.updateAccessTime(localPath); - return localPath; - } - - try { - // 下载文件 - const downloadResult = await FileSystem.downloadAsync(uri, localPath); - - // 记录缓存信息 - const cacheInfo: MediaCacheInfo = { - type, - uri, - localPath, - conversationId, - messageId, - size: downloadResult.headers['content-length'] - ? parseInt(downloadResult.headers['content-length']) - : 0, - createdAt: Date.now(), - lastAccessedAt: Date.now(), - }; - - await this.saveMediaRecord(localPath, cacheInfo); - await this.cleanupIfNeeded(); - - return localPath; - } catch (error) { - console.error('[MediaCacheManager] 缓存失败:', error); - throw error; - } - } - - // 获取缓存的媒体文件路径 - async getCachedMedia(uri: string, type: MediaType): Promise { - const record = this.findByUri(uri); - if (record && await this.exists(record.localPath)) { - await this.updateAccessTime(record.localPath); - return record.localPath; - } - return null; - } - - // 删除单个会话的所有媒体缓存 - async clearConversationMedia(conversationId: string): Promise { - const toDelete: string[] = []; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId === conversationId) { - toDelete.push(path); - } - } - - await Promise.all(toDelete.map(p => this.deleteMedia(p))); - } - - // 删除过期媒体 - async clearExpiredMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAge * 60 * 60 * 1000; // 转换为毫秒 - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (now - info.lastAccessedAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 清理超过会话保留期的媒体 - async clearOldConversationMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAgeForConversation * 24 * 60 * 60 * 1000; - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId && now - info.createdAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 按大小清理(LRU) - async clearBySizeLimit(): Promise { - const totalSize = await this.getTotalCacheSize(); - const limit = this.config.image.maxDiskCacheSize * 1024 * 1024; - - if (totalSize <= limit) { - return 0; - } - - // 按最后访问时间排序 - const sorted = Array.from(this.mediaRecords.entries()) - .sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); - - let currentSize = totalSize; - let deletedCount = 0; - - for (const [path, info] of sorted) { - if (currentSize <= limit) break; - - await this.deleteMedia(path); - currentSize -= info.size; - deletedCount++; - } - - return deletedCount; - } - - // 获取缓存统计 - async getCacheStats(): Promise<{ - totalSize: number; - imageCount: number; - videoCount: number; - audioCount: number; - oldestItem: number; - newestItem: number; - }> { - const stats = { - totalSize: 0, - imageCount: 0, - videoCount: 0, - audioCount: 0, - oldestItem: Date.now(), - newestItem: 0, - }; - - for (const info of this.mediaRecords.values()) { - stats.totalSize += info.size; - stats.oldestItem = Math.min(stats.oldestItem, info.createdAt); - stats.newestItem = Math.max(stats.newestItem, info.createdAt); - - switch (info.type) { - case MediaType.IMAGE: stats.imageCount++; break; - case MediaType.VIDEO: stats.videoCount++; break; - case MediaType.AUDIO: stats.audioCount++; break; - } - } - - return stats; - } - - // 清理入口 - async cleanupIfNeeded(): Promise { - await this.clearExpiredMedia(); - await this.clearBySizeLimit(); - } - - // 私有辅助方法 - private generateFilename(uri: string, type: MediaType): string { - const ext = this.getExtension(uri, type); - const hash = this.hashString(uri); - return `${type}_${hash}_${Date.now()}.${ext}`; - } - - private getExtension(uri: string, type: MediaType): string { - if (type === MediaType.IMAGE) { - if (uri.includes('.gif')) return 'gif'; - if (uri.includes('.webp')) return 'webp'; - return 'jpg'; - } - if (type === MediaType.VIDEO) return 'mp4'; - if (type === MediaType.AUDIO) return 'mp3'; - return 'bin'; - } - - private getMediaPath(type: MediaType, filename: string): string { - switch (type) { - case MediaType.IMAGE: return `${this.IMAGE_DIR}${filename}`; - case MediaType.VIDEO: return `${this.VIDEO_DIR}${filename}`; - case MediaType.AUDIO: return `${this.AUDIO_DIR}${filename}`; - } - } - - private async exists(path: string): Promise { - const info = await FileSystem.getInfoAsync(path); - return info.exists; - } - - private async deleteMedia(path: string): Promise { - try { - await FileSystem.deleteAsync(path, { idempotent: true }); - this.mediaRecords.delete(path); - await this.removeMediaRecord(path); - } catch (error) { - console.warn('[MediaCacheManager] 删除失败:', path, error); - } - } - - private async saveMediaRecord(path: string, info: MediaCacheInfo): Promise { - this.mediaRecords.set(path, info); - await cacheDataSource.set(`media_${path}`, info, null); - } - - private async loadMediaRecords(): Promise { - // 从缓存数据源加载 - } - - private async updateAccessTime(path: string): Promise { - const info = this.mediaRecords.get(path); - if (info) { - info.lastAccessedAt = Date.now(); - await this.saveMediaRecord(path, info); - } - } - - private findByUri(uri: string): MediaCacheInfo | undefined { - for (const info of this.mediaRecords.values()) { - if (info.uri === uri) { - return info; - } - } - return undefined; - } - - private async getTotalCacheSize(): Promise { - let total = 0; - for (const info of this.mediaRecords.values()) { - total += info.size; - } - return total; - } - - private hashString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash).toString(16); - } -} - -export const mediaCacheManager = new MediaCacheManager(); -``` - -#### 3. 创建清理策略 - -```typescript -// src/services/media/MediaCleanupPolicy.ts - -export enum CleanupTrigger { - STARTUP = 'startup', - SCHEDULED = 'scheduled', - MANUAL = 'manual', - LOW_STORAGE = 'low_storage', - CONVERSATION_DELETED = 'conversation_deleted', -} - -export interface CleanupResult { - trigger: CleanupTrigger; - timestamp: number; - deletedCount: number; - freedSize: number; - errors: string[]; -} - -class MediaCleanupPolicy { - private lastCleanup: number = 0; - private config: MediaCacheConfig; - - // 启动时清理 - async cleanupOnStartup(): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - // 清理过期文件 - const expired = await mediaCacheManager.clearExpiredMedia(); - deletedCount += expired; - - // 清理超过保留期的会话媒体 - const oldConversation = await mediaCacheManager.clearOldConversationMedia(); - deletedCount += oldConversation; - - // 超过大小限制时清理 - const bySize = await mediaCacheManager.clearBySizeLimit(); - deletedCount += bySize; - - this.lastCleanup = Date.now(); - } catch (error) { - errors.push(`Startup cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.STARTUP, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } - - // 检查是否需要定期清理 - shouldRunScheduledCleanup(): boolean { - const interval = this.config.global.checkInterval * 60 * 60 * 1000; - return Date.now() - this.lastCleanup > interval; - } - - // 会话删除时清理 - async cleanupOnConversationDeleted(conversationId: string): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - const statsBefore = await mediaCacheManager.getCacheStats(); - await mediaCacheManager.clearConversationMedia(conversationId); - const statsAfter = await mediaCacheManager.getCacheStats(); - - deletedCount = statsBefore.imageCount + statsBefore.videoCount + statsBefore.audioCount - - (statsAfter.imageCount + statsAfter.videoCount + statsAfter.audioCount); - freedSize = statsBefore.totalSize - statsAfter.totalSize; - } catch (error) { - errors.push(`Conversation cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.CONVERSATION_DELETED, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } -} - -export const mediaCleanupPolicy = new MediaCleanupPolicy(); -``` - -#### 4. 创建清理 Hook - -```typescript -// src/hooks/useMediaCache.ts - -export function useMediaCache() { - const [stats, setStats] = useState(null); - const [isClearing, setIsClearing] = useState(false); - - // 加载缓存统计 - const loadStats = useCallback(async () => { - const cacheStats = await mediaCacheManager.getCacheStats(); - setStats(cacheStats); - }, []); - - // 手动清理 - const clearAll = useCallback(async () => { - setIsClearing(true); - try { - await mediaCacheManager.clearExpiredMedia(); - await mediaCacheManager.clearBySizeLimit(); - await loadStats(); - } finally { - setIsClearing(false); - } - }, [loadStats]); - - // 清理指定会话的媒体 - const clearConversation = useCallback(async (conversationId: string) => { - await mediaCacheManager.clearConversationMedia(conversationId); - await loadStats(); - }, [loadStats]); - - // 组件挂载时加载统计 - useEffect(() => { - loadStats(); - }, [loadStats]); - - return { - stats, - isClearing, - loadStats, - clearAll, - clearConversation, - formatSize: (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; - }, - }; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/media/MediaCacheConfig.ts` | 新增 | 媒体缓存配置 | -| `src/services/media/MediaCacheManager.ts` | 新增 | 媒体缓存管理器 | -| `src/services/media/MediaCleanupPolicy.ts` | 新增 | 清理策略 | -| `src/services/media/index.ts` | 新增 | 导出文件 | -| `src/hooks/useMediaCache.ts` | 新增 | 媒体缓存 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | -| `src/data/datasources/CacheDataSource.ts` | 修改 | 添加媒体记录存储 | - -### 清理策略 - -```mermaid -graph TD - A[启动 App] --> B{检查启动清理标志} - B -->|是| C[清理过期媒体] - B -->|是| D[清理超期会话媒体] - B -->|是| E[检查大小限制] - E -->|超过限制| F[LRU 清理] - - G[定期检查] -->|间隔到期| C - G -->|间隔到期| D - G -->|间隔到期| E - - H[用户操作] -->|删除会话| I[清理该会话媒体] - H -->|手动清理| J[清理所有过期媒体] - - K[存储空间检查] -->|低于阈值| L[紧急清理] -``` - ---- - -## 实现优先级与依赖关系 - -### 优先级矩阵 - -| 优先级 | 功能 | 依赖 | 复杂度 | -|--------|------|------|--------| -| P0 | 分页状态管理 | 无 | 中 | -| P0 | 同步状态机 | 无 | 低 | -| P1 | 差异更新 | P0 分页状态管理 | 高 | -| P1 | 媒体缓存清理 | 无 | 中 | - -### 推荐实现顺序 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 1: 基础改进 (1-2天) │ -│ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ P0 分页状态管理 │ │ P0 同步状态机 │ │ -│ │ │ │ │ │ -│ │ - 避免重复加载 │ │ - 清晰状态反馈 │ │ -│ │ - 页面缓存 │ │ - 连接状态 Hook │ │ -│ │ - 预加载机制 │ │ - UI 状态指示器 │ │ -│ └───────────────────┘ └───────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 2: 性能优化 (2-3天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 差异更新 ││ -│ │ ││ -│ │ - 依赖分页状态管理 ││ -│ │ - 消息更新类型定义 ││ -│ │ - 差异更新 Hook ││ -│ │ - FlatList 渲染优化 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 3: 存储优化 (1-2天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 媒体缓存清理 ││ -│ │ ││ -│ │ - 媒体缓存管理器 ││ -│ │ - 清理策略 ││ -│ │ - 用户清理界面 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 风险与注意事项 - -1. **差异更新风险** - - 需要确保消息 ID 的唯一性 - - 并发更新时需要正确合并 - - 建议在开发环境进行大房间压力测试 - -2. **分页状态管理风险** - - 页面缓存会占用内存,需要设置上限 - - 分页状态需要正确序列化和恢复(跨会话) - -3. **状态机风险** - - 状态转换需要严格测试 - - 重连逻辑需要处理边界情况(如网络中断) - -4. **媒体缓存风险** - - 删除文件时需要确保没有正在使用 - - 缓存元数据需要持久化存储 - - 清理操作应该在后台线程执行 \ No newline at end of file diff --git a/plans/frontend_cursor_pagination_design.md b/plans/frontend_cursor_pagination_design.md new file mode 100644 index 0000000..00268ed --- /dev/null +++ b/plans/frontend_cursor_pagination_design.md @@ -0,0 +1,1131 @@ +# 前端游标分页适配方案设计文档 + +## 一、背景说明 + +后端已完成游标分页功能实现,共改造了 11 个接口。前端需要对接这些新的游标分页接口,替换现有的基于页码的分页方式。 + +### 后端游标分页接口列表 + +| 模块 | 接口路径 | 排序方式 | +|------|----------|----------| +| 帖子 | GET /posts/cursor | created_at DESC | +| 帖子搜索 | GET /posts/search/cursor | created_at DESC | +| 用户帖子 | GET /users/:id/posts/cursor | created_at DESC | +| 会话列表 | GET /conversations/cursor | updated_at DESC | +| 消息列表 | GET /conversations/:id/messages/cursor | seq DESC | +| 评论列表 | GET /comments/post/:id/cursor | created_at DESC | +| 评论回复 | GET /comments/:id/replies/cursor | created_at ASC | +| 通知列表 | GET /notifications/cursor | created_at DESC | +| 群组列表 | GET /groups/cursor | created_at DESC | +| 群成员 | GET /groups/:id/members/cursor | join_time DESC | +| 群公告 | GET /groups/:id/announcements/cursor | created_at DESC | + +### 后端 API 规范 + +**请求参数:** +- `cursor` - 游标字符串(首次请求不传) +- `direction` - 分页方向:`forward` 或 `backward`(默认 forward) +- `page_size` - 每页数量(默认 20,最大 100) + +**响应格式:** +```json +{ + "items": [...], + "next_cursor": "xxx", + "prev_cursor": "xxx", + "has_more": true +} +``` + +--- + +## 二、现有前端分页机制分析 + +### 2.1 分页类型定义 (`src/infrastructure/pagination/types.ts`) + +```typescript +// 现有分页状态 - 基于页码 +interface PaginationState { + currentPage: number; + pageSize: number; + totalLoaded: number; + hasMore: boolean; + isLoading: boolean; + lastLoadTime: number; + cursor?: string | number | null; // 已支持但未充分使用 + hasError: boolean; + error?: string; +} + +// 现有 FetchPageFunction +type FetchPageFunction = ( + page: number, + pageSize: number, + cursor?: string | number | null +) => Promise<{ + data: T[]; + hasMore: boolean; + cursor?: string | number | null; +}>; +``` + +### 2.2 分页状态管理器 (`PaginationStateManager.ts`) + +- 使用 `currentPage` 作为分页基准 +- 基于页码的缓存机制(`pageCaches: Map>`) +- `loadMore()` 方法自动递增页码 + +### 2.3 usePagination Hook (`usePagination.ts`) + +- 暴露 `currentPage`、`loadMore`、`refresh` 等方法 +- 内部调用 `PaginationStateManager` 进行状态管理 +- 已有 `cursor` 支持,但 UI 层仍使用页码 + +### 2.4 现有 API 服务层 + +**postService.ts:** +```typescript +// 现有分页方式 +async getPosts(page = 1, pageSize = 20, tab?: string): Promise> +``` + +**messageService.ts:** +```typescript +// 现有分页方式 - 基于 seq +async getMessages(conversationId, afterSeq?, beforeSeq?, limit?): Promise +``` + +### 2.5 现有页面组件分页方式 + +各页面使用**手动管理状态**的方式: +```typescript +const [page, setPage] = useState(1); +const [hasMore, setHasMore] = useState(true); + +const loadMore = useCallback(() => { + if (!loading && hasMore) { + const nextPage = page + 1; + setPage(nextPage); + loadData(nextPage); + } +}, [loading, hasMore, page, loadData]); +``` + +**涉及页面:** +- `HomeScreen.tsx` - 帖子列表 +- `SearchScreen.tsx` - 帖子搜索 +- `PostDetailScreen.tsx` - 帖子详情(评论列表) +- `MessageListScreen.tsx` - 会话列表 +- `ChatScreen.tsx` - 消息列表 +- `NotificationsScreen.tsx` - 通知列表 +- `GroupMembersScreen.tsx` - 群组成员 +- `JoinGroupScreen.tsx` - 群组列表 + +--- + +## 三、游标分页类型定义设计 + +### 3.1 新增游标分页相关类型 (`src/infrastructure/pagination/types.ts`) + +```typescript +// ==================== 游标分页类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页请求参数 + */ +export interface CursorPageRequest { + cursor?: string | null; + direction?: CursorDirection; + page_size?: number; +} + +/** + * 游标分页响应 + */ +export interface CursorPageResponse { + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +} + +/** + * 游标分页状态 - 扩展现有 PaginationState + */ +export interface CursorPaginationState { + // 游标相关 + nextCursor: string | null; + prevCursor: string | null; + currentCursor: string | null; + + // 方向 + direction: CursorDirection; + + // 通用 + hasMore: boolean; + isLoading: boolean; + hasError: boolean; + error?: string; +} + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + pageSize: number; + prefetchThreshold: number; + enablePrefetch: boolean; + maxRetries: number; + retryDelay: number; +} + +/** + * 游标分页加载函数 + */ +export type CursorFetchFunction = ( + request: CursorPageRequest +) => Promise>; + +/** + * 游标分页加载结果 + */ +export interface CursorLoadMoreResult { + success: boolean; + data: T[]; + hasMore: boolean; + nextCursor: string | null; + prevCursor: string | null; + fromCache: boolean; + error?: string; +} + +/** + * 游标分页刷新结果 + */ +export interface CursorRefreshResult { + success: boolean; + data: T[]; + hasMore: boolean; + nextCursor: string | null; + error?: string; +} +``` + +### 3.2 新增游标分页 DTO 类型 (`src/types/dto.ts`) + +```typescript +// ==================== 游标分页 DTO ==================== + +/** + * 游标分页响应基础结构 + */ +export interface CursorPaginatedResponse { + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +} + +/** + * 帖子列表游标分页响应 + */ +export interface PostCursorResponse extends CursorPaginatedResponse {} + +/** + * 会话列表游标分页响应 + */ +export interface ConversationCursorResponse extends CursorPaginatedResponse {} + +/** + * 消息列表游标分页响应 + */ +export interface MessageCursorResponse extends CursorPaginatedResponse {} + +/** + * 评论列表游标分页响应 + */ +export interface CommentCursorResponse extends CursorPaginatedResponse {} + +/** + * 通知列表游标分页响应 + */ +export interface NotificationCursorResponse extends CursorPaginatedResponse {} + +/** + * 群组列表游标分页响应 + */ +export interface GroupCursorResponse extends CursorPaginatedResponse {} + +/** + * 群成员列表游标分页响应 + */ +export interface GroupMemberCursorResponse extends CursorPaginatedResponse {} + +/** + * 群公告列表游标分页响应 + */ +export interface GroupAnnouncementCursorResponse extends CursorPaginatedResponse {} +``` + +--- + +## 四、API 服务层改造设计 + +### 4.1 新增游标分页 API 方法 + +#### postService.ts 改造 +```typescript +class PostService { + // ==================== 游标分页方法 ==================== + + /** + * 获取帖子列表(游标分页) + * GET /api/v1/posts/cursor + */ + async getPostsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/posts/cursor', params); + return { + items: response.data.items, + next_cursor: response.data.next_cursor, + prev_cursor: response.data.prev_cursor, + has_more: response.data.has_more, + }; + } + + /** + * 搜索帖子(游标分页) + * GET /api/v1/posts/search/cursor + */ + async searchPostsCursor( + keyword: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + keyword, + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/posts/search/cursor', params); + return response.data; + } + + /** + * 获取用户帖子列表(游标分页) + * GET /api/v1/users/:id/posts/cursor + */ + async getUserPostsCursor( + userId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/users/${userId}/posts/cursor`, + params + ); + return response.data; + } +} +``` + +#### messageService.ts 改造 +```typescript +class MessageService { + // ==================== 会话列表游标分页 ==================== + + /** + * 获取会话列表(游标分页) + * GET /api/v1/conversations/cursor + */ + async getConversationsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/conversations/cursor', params); + return response.data; + } + + // ==================== 消息列表游标分页 ==================== + + /** + * 获取消息列表(游标分页) + * GET /api/v1/conversations/:id/messages/cursor + */ + async getMessagesCursor( + conversationId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, + params + ); + return response.data; + } +} +``` + +#### 新增 commentService.ts +```typescript +// 新建文件: src/services/commentService.ts + +import { api } from './api'; +import { CommentCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; +import { CommentDTO } from '../types/dto'; + +class CommentService { + /** + * 获取帖子评论列表(游标分页) + * GET /api/v1/comments/post/:id/cursor + */ + async getPostCommentsCursor( + postId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/comments/post/${postId}/cursor`, + params + ); + return response.data; + } + + /** + * 获取评论回复列表(游标分页) + * GET /api/v1/comments/:id/replies/cursor + */ + async getCommentRepliesCursor( + commentId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/comments/${commentId}/replies/cursor`, + params + ); + return response.data; + } +} + +export const commentService = new CommentService(); +``` + +#### 新增 notificationService.ts +```typescript +// 新建文件: src/services/notificationService.ts + +import { api } from './api'; +import { NotificationCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; +import { NotificationDTO } from '../types/dto'; + +class NotificationService { + /** + * 获取通知列表(游标分页) + * GET /api/v1/notifications/cursor + */ + async getNotificationsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/notifications/cursor', params); + return response.data; + } +} + +export const notificationService = new NotificationService(); +``` + +#### 新增 groupService.ts (扩展现有群组服务) +```typescript +// 在现有 groupService.ts 中添加游标分页方法 + +class GroupService { + /** + * 获取群组列表(游标分页) + * GET /api/v1/groups/cursor + */ + async getGroupsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/groups/cursor', params); + return response.data; + } + + /** + * 获取群成员列表(游标分页) + * GET /api/v1/groups/:id/members/cursor + */ + async getGroupMembersCursor( + groupId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/groups/${groupId}/members/cursor`, + params + ); + return response.data; + } + + /** + * 获取群公告列表(游标分页) + * GET /api/v1/groups/:id/announcements/cursor + */ + async getGroupAnnouncementsCursor( + groupId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/groups/${groupId}/announcements/cursor`, + params + ); + return response.data; + } +} +``` + +--- + +## 五、分页 Hook 改造设计 + +### 5.1 新增 useCursorPagination Hook (`src/hooks/useCursorPagination.ts`) + +```typescript +/** + * 游标分页 Hook + * + * 提供基于游标的分页功能,包括: + * - loadMore: 加载下一页 + * - loadPrevious: 加载上一页(双向游标支持) + * - refresh: 刷新数据 + * - 状态管理 + */ + +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { CursorPaginationState, CursorPaginationConfig } from '../infrastructure/pagination/types'; + +export interface UseCursorPaginationOptions { + /** 分页键 */ + key: string | null; + /** 数据获取函数 */ + fetchFunction: CursorFetchFunction; + /** 分页配置 */ + config?: Partial; + /** 初始数据 */ + initialData?: T[]; + /** 是否自动加载第一页 */ + autoLoad?: boolean; + /** 是否启用自动预加载 */ + enableAutoPrefetch?: boolean; +} + +export interface UseCursorPaginationReturn { + /** 当前数据列表 */ + data: T[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否有上一页 */ + hasPrevious: boolean; + /** 是否有错误 */ + hasError: boolean; + /** 错误信息 */ + error?: string; + /** 当前游标 */ + currentCursor: string | null; + /** 加载下一页 */ + loadMore: () => Promise>; + /** 加载上一页 */ + loadPrevious: () => Promise>; + /** 刷新数据 */ + refresh: () => Promise>; + /** 重置分页状态 */ + reset: () => void; +} + +export function useCursorPagination( + options: UseCursorPaginationOptions +): UseCursorPaginationReturn { + const { + key, + fetchFunction, + config: userConfig, + initialData = [], + autoLoad = false, + enableAutoPrefetch = true, + } = options; + + // 合并配置 + const config = useMemo(() => ({ + ...DEFAULT_CURSOR_PAGINATION_CONFIG, + ...userConfig, + }), [userConfig]); + + // 数据状态 + const [data, setData] = useState(initialData); + const [state, setState] = useState({ + nextCursor: null, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: true, + isLoading: false, + hasError: false, + error: undefined, + }); + + // Refs + const autoLoadRef = useRef(false); + const isMountedRef = useRef(true); + + // 派生的 UI 状态 + const isRefreshing = state.isLoading && data.length > 0; + const hasPrevious = state.prevCursor !== null; + + /** + * 加载下一页 + */ + const loadMore = useCallback(async (): Promise> => { + if (!key) { + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No pagination key' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: state.nextCursor, + direction: 'forward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(prev => [...prev, ...response.items]); + setState({ + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + currentCursor: response.next_cursor, + direction: 'forward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + fromCache: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; + } + }, [key, fetchFunction, state.nextCursor, config.pageSize]); + + /** + * 加载上一页 + */ + const loadPrevious = useCallback(async (): Promise> => { + if (!key || !state.prevCursor) { + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No previous page' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: state.prevCursor, + direction: 'backward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(prev => [...response.items, ...prev]); + setState({ + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + currentCursor: response.prev_cursor, + direction: 'backward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + fromCache: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; + } + }, [key, fetchFunction, state.prevCursor, config.pageSize]); + + /** + * 刷新数据 + */ + const refresh = useCallback(async (): Promise> => { + if (!key) { + return { success: false, data: [], hasMore: false, nextCursor: null, error: 'No pagination key' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: null, + direction: 'forward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(response.items); + setState({ + nextCursor: response.next_cursor, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, error: errorMessage }; + } + }, [key, fetchFunction, config.pageSize]); + + /** + * 重置分页状态 + */ + const reset = useCallback(() => { + if (isMountedRef.current) { + setData([]); + setState({ + nextCursor: null, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: true, + isLoading: false, + hasError: false, + }); + autoLoadRef.current = false; + } + }, []); + + // 自动加载第一页 + useEffect(() => { + if (!key || !autoLoad || autoLoadRef.current) return; + if (data.length > 0) return; + + autoLoadRef.current = true; + refresh(); + }, [key, autoLoad, data.length, refresh]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + return { + data, + isLoading: state.isLoading, + isRefreshing, + hasMore: state.hasMore, + hasPrevious, + hasError: state.hasError, + error: state.error, + currentCursor: state.currentCursor, + loadMore, + loadPrevious, + refresh, + reset, + }; +} +``` + +--- + +## 六、各列表页面改造计划 + +### 6.1 帖子列表 (HomeScreen.tsx) + +```typescript +// 改造后的分页方式 +const { + data: posts, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'home-posts', + fetchFunction: async (request) => { + return postService.getPostsCursor(request); + }, + config: { pageSize: 20 }, + autoLoad: true, +}); + +// UI 层使用 FlatList/FlashList 的 onEndReached + 0} + onRefresh={refresh} +/> +``` + +### 6.2 会话列表 (MessageListScreen.tsx) + +```typescript +const { + data: conversations, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'conversations', + fetchFunction: async (request) => { + return messageService.getConversationsCursor(request); + }, + autoLoad: true, +}); +``` + +### 6.3 消息列表 (ChatScreen.tsx) + +```typescript +// 聊天消息需要支持双向加载 +const { + data: messages, + isLoading, + hasMore, + hasPrevious, + loadMore, // 加载更多历史 + loadPrevious, // 加载更新的消息 + refresh, +} = useCursorPagination({ + key: `chat-${conversationId}`, + fetchFunction: async (request) => { + return messageService.getMessagesCursor(conversationId, request); + }, +}); +``` + +### 6.4 帖子搜索 (SearchScreen.tsx) + +```typescript +const { + data: searchResults, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `search-${keyword}`, + fetchFunction: async (request) => { + return postService.searchPostsCursor(keyword, request); + }, + autoLoad: !!keyword, +}); +``` + +### 6.5 评论列表 (PostDetailScreen.tsx) + +```typescript +const { + data: comments, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `comments-${postId}`, + fetchFunction: async (request) => { + return commentService.getPostCommentsCursor(postId, request); + }, +}); +``` + +### 6.6 通知列表 (NotificationsScreen.tsx) + +```typescript +const { + data: notifications, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'notifications', + fetchFunction: async (request) => { + return notificationService.getNotificationsCursor(request); + }, +}); +``` + +### 6.7 群组列表 (JoinGroupScreen.tsx) + +```typescript +const { + data: groups, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'groups', + fetchFunction: async (request) => { + return groupService.getGroupsCursor(request); + }, +}); +``` + +### 6.8 群组成员 (GroupMembersScreen.tsx) + +```typescript +const { + data: members, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `group-members-${groupId}`, + fetchFunction: async (request) => { + return groupService.getGroupMembersCursor(groupId, request); + }, +}); +``` + +--- + +## 七、需要修改的文件清单 + +### 7.1 新增文件 + +| 文件路径 | 说明 | +|----------|------| +| `src/hooks/useCursorPagination.ts` | 新增游标分页 Hook | +| `src/services/commentService.ts` | 新增评论服务(含游标分页) | +| `src/services/notificationService.ts` | 新增通知服务(含游标分页) | +| `src/types/cursor.ts` | 新增游标分页相关类型定义 | + +### 7.2 需要修改的文件 + +| 文件路径 | 修改内容 | +|----------|----------| +| `src/types/dto.ts` | 新增游标分页 DTO 类型 | +| `src/services/postService.ts` | 新增游标分页方法(保留旧方法兼容) | +| `src/services/messageService.ts` | 新增会话/消息游标分页方法 | +| `src/services/groupService.ts` | 新增群组相关游标分页方法 | +| `src/infrastructure/pagination/types.ts` | 新增游标分页类型定义 | +| `src/infrastructure/pagination/CursorPaginationStateManager.ts` | 新增游标分页状态管理器(可选) | +| `src/screens/home/HomeScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/home/SearchScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/home/PostDetailScreen.tsx` | 改造评论列表使用 useCursorPagination | +| `src/screens/message/MessageListScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/ChatScreen.tsx` | 改造消息列表使用 useCursorPagination | +| `src/screens/message/NotificationsScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/JoinGroupScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/GroupMembersScreen.tsx` | 改造为使用 useCursorPagination | + +--- + +## 八、实现顺序建议 + +### 阶段一:基础设施(优先级高) +1. 在 `src/types/dto.ts` 新增游标分页 DTO 类型 +2. 在 `src/infrastructure/pagination/types.ts` 新增游标分页类型 +3. 创建 `src/hooks/useCursorPagination.ts` + +### 阶段二:API 服务层 +4. 改造 `postService.ts` - 新增游标分页方法 +5. 改造 `messageService.ts` - 新增游标分页方法 +6. 创建 `commentService.ts` +7. 创建 `notificationService.ts` +8. 改造 `groupService.ts` - 新增游标分页方法 + +### 阶段三:UI 组件改造(按依赖顺序) +9. `HomeScreen.tsx` - 帖子列表 +10. `SearchScreen.tsx` - 帖子搜索 +11. `PostDetailScreen.tsx` - 评论列表 +12. `MessageListScreen.tsx` - 会话列表 +13. `ChatScreen.tsx` - 消息列表 +14. `NotificationsScreen.tsx` - 通知列表 +15. `JoinGroupScreen.tsx` - 群组列表 +16. `GroupMembersScreen.tsx` - 群组成员 + +--- + +## 九、架构图 + +```mermaid +graph TB + subgraph "UI Layer" + HomeScreen[HomeScreen.tsx] + SearchScreen[SearchScreen.tsx] + ChatScreen[ChatScreen.tsx] + MessageListScreen[MessageListScreen.tsx] + NotificationsScreen[NotificationsScreen.tsx] + GroupMembersScreen[GroupMembersScreen.tsx] + end + + subgraph "Hooks Layer" + useCursorPagination[useCursorPagination.ts] + usePagination[usePagination.ts - 保留兼容] + end + + subgraph "Service Layer" + postService[postService.ts] + messageService[messageService.ts] + commentService[commentService.ts] + notificationService[notificationService.ts] + groupService[groupService.ts] + end + + subgraph "API Layer" + API[API Client] + end + + subgraph "Backend" + CursorAPI[游标分页 API] + end + + HomeScreen --> useCursorPagination + SearchScreen --> useCursorPagination + ChatScreen --> useCursorPagination + MessageListScreen --> useCursorPagination + NotificationsScreen --> useCursorPagination + GroupMembersScreen --> useCursorPagination + + useCursorPagination --> postService + useCursorPagination --> messageService + useCursorPagination --> commentService + useCursorPagination --> notificationService + useCursorPagination --> groupService + + postService --> API + messageService --> API + commentService --> API + notificationService --> API + groupService --> API + + API --> CursorAPI +``` + +--- + +## 十、注意事项 + +### 10.1 兼容性考虑 +- 保留现有的页码分页方法一段时间,避免一次性全量替换带来的风险 +- 通过功能开关或配置控制是否启用游标分页 + +### 10.2 消息列表特殊处理 +- 聊天消息需要支持双向加载(加载历史 + 加载最新) +- 需要处理好新消息的插入位置(头部 vs 尾部) + +### 10.3 缓存策略 +- 游标分页的缓存策略需要调整,不再基于页码 +- 可以考虑基于 cursor 字符串进行缓存 + +### 10.4 错误处理 +- 网络错误时需要显示重试选项 +- 游标失效时需要提示用户刷新列表 diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88416f5..d18b9d8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -83,6 +83,9 @@ export type { UsePaginationReturn, } from './usePagination'; +// ==================== 游标分页 Hooks ==================== +export { useCursorPagination } from './useCursorPagination'; + // ==================== 连接状态 Hooks ==================== export { useConnectionState } from './useConnectionState'; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts new file mode 100644 index 0000000..403316e --- /dev/null +++ b/src/hooks/useCursorPagination.ts @@ -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( + fetchFunction: CursorFetchFunction, + config: Partial = {}, + extraParams?: P +): UseCursorPaginationReturn { + const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config; + + // 限制 pageSize 在有效范围内 + const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); + + const [state, setState] = useState>({ + 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 = 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 = 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 = 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; \ No newline at end of file diff --git a/src/infrastructure/pagination/index.ts b/src/infrastructure/pagination/index.ts index de25c21..495dd7d 100644 --- a/src/infrastructure/pagination/index.ts +++ b/src/infrastructure/pagination/index.ts @@ -19,6 +19,16 @@ export type { FetchPageFunction, } from './types'; +// 导出游标分页相关类型 +export type { + CursorDirection, + CursorPaginationConfig, + CursorPaginationState, + CursorPaginationActions, + UseCursorPaginationReturn, + CursorFetchFunction, +} from './types'; + // 导出工具函数和常量 export { DEFAULT_PAGINATION_CONFIG, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index 6c96bab..3ea56de 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -180,3 +180,79 @@ export function createPageCache( export function isCacheExpired(cache: PageCache, ttl: number): boolean { return Date.now() - cache.cachedAt > ttl; } + +// ==================== 游标分页相关类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + /** 每页数量 */ + pageSize: number; + /** 是否启用双向分页 */ + bidirectional?: boolean; +} + +/** + * 游标分页状态 + */ +export interface CursorPaginationState { + /** 数据项列表 */ + items: T[]; + /** 下一页游标 */ + nextCursor: string | null; + /** 上一页游标 */ + prevCursor: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否为首次加载 */ + isFirstLoad: boolean; +} + +/** + * 游标分页操作 + */ +export interface CursorPaginationActions { + /** 加载更多(下一页) */ + loadMore: () => Promise; + /** 加载上一页(双向分页) */ + loadPrevious: () => Promise; + /** 刷新数据(重新从第一页加载) */ + refresh: () => Promise; + /** 重置状态 */ + reset: () => void; + /** 设置数据(用于外部数据注入) */ + setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; +} + +/** + * 游标分页 Hook 返回值 + */ +export interface UseCursorPaginationReturn extends CursorPaginationState, CursorPaginationActions {} + +/** + * 游标分页数据获取函数 + */ +export type CursorFetchFunction = (params: { + cursor?: string; + direction: CursorDirection; + pageSize: number; + /** 额外参数 */ + extraParams?: P; +}) => Promise<{ + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +}>; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index c55e5ad..c889eb0 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -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 & 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(); 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([]); - 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('list'); // 图片查看器状态 @@ -95,18 +86,56 @@ export const HomeScreen: React.FC = () => { const [showCreatePost, setShowCreatePost] = useState(false); // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 - const postIdsRef = React.useRef>(new Set()); - const inFlightRequestKeysRef = React.useRef>(new Set()); - const lastLoadMoreTriggerAtRef = useRef(0); + const postIdsRef = useRef>(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( + 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(); - 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) => { - 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={ @@ -594,7 +481,7 @@ export const HomeScreen: React.FC = () => { // 渲染空状态 const renderEmpty = () => { - if (loading) return null; + if (isLoading) return null; return ( { showsVerticalScrollIndicator={false} refreshControl={ } - onEndReached={onEndReached} + onEndReached={loadMore} onEndReachedThreshold={0.3} ListEmptyComponent={renderEmpty} - ListFooterComponent={loadingMore ? : null} + ListFooterComponent={isLoading ? : null} /> ); }; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index af3d886..b04f000 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -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([]); 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={ } + onEndReached={loadMoreComments} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isCommentsLoading ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : comments.length > 0 ? ( + + 没有更多评论了 + + ) : null + } /> ); @@ -1433,7 +1480,7 @@ export const PostDetailScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ { // 移动端单栏布局 return ( - { }} refreshControl={ } + onEndReached={loadMoreComments} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isCommentsLoading ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : comments.length > 0 ? ( + + 没有更多评论了 + + ) : 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, + }, }); diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index ac4774a..0e728a4 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -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; const TABS = ['帖子', '用户']; +const DEFAULT_PAGE_SIZE = 20; type SearchType = 'posts' | 'users'; @@ -74,17 +77,47 @@ export const SearchScreen: React.FC = ({ 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( + 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([]); + 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 = ({ 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 = ({ onBack, navigation: // 渲染帖子搜索结果(使用响应式网格) const renderPostResults = () => { - const posts = searchResults.posts; + const posts = searchResults; - if (posts.length === 0) { + if (posts.length === 0 && !isLoading) { return ( = ({ onBack, navigation: + } > = ({ onBack, navigation: /> ))} + {isLoading && ( + + + + )} ); } @@ -220,15 +260,26 @@ export const SearchScreen: React.FC = ({ onBack, navigation: keyExtractor={item => item.id} contentContainerStyle={{ paddingBottom: responsivePadding }} showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListFooterComponent={isLoading ? : null} /> ); }; // 渲染用户搜索结果 const renderUserResults = () => { - const users = searchResults.users; + const users = userResults; - if (users.length === 0) { + if (users.length === 0 && !userLoading) { return ( = ({ onBack, navigation: ))} + {userLoading && ( + + + + )} ); } @@ -329,6 +385,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: keyExtractor={item => item.id} contentContainerStyle={{ paddingVertical: responsiveGap }} showsVerticalScrollIndicator={false} + ListFooterComponent={userLoading ? : 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, }, }); diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 3612f99..ffb281b 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -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([]); + // 使用游标分页 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([]); 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(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 ; + if (loading || isLoading) return ; return ( { keyExtractor={(item) => item.title} refreshControl={ { onEndReached={loadMore} onEndReachedThreshold={0.3} showsVerticalScrollIndicator={false} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多成员 + + + ) : localMembers.length > 0 ? ( + + 没有更多成员了 + + ) : null + } renderItem={({ item: group }) => ( {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; diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 378b1f1..e6df582 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -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; @@ -18,10 +30,29 @@ const JoinGroupScreen: React.FC = () => { const navigation = useNavigation(); const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); - const [joining, setJoining] = useState(false); - const [group, setGroup] = useState(null); + const [joiningGroupId, setJoiningGroupId] = useState(null); + const [searchedGroup, setSearchedGroup] = useState(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 ( + + + + + + {group.name} + + + + {!!group.description && ( + + {group.description} + + )} + + + 成员 {group.member_count}/{group.max_members} + + + {getJoinTypeText(group.join_type)} + + + + + 群号:{formatGroupNo(group.id)} + + handleCopyGroupId(group.id)}> + + + 复制 + + + + handleJoin(group)} + disabled={isJoining} + > + {isJoining ? ( + + ) : ( + <> + + + 申请入群 + + + )} + + + ); + }; + + const renderEmptyList = () => { + if (isLoading) return null; + return ( + + ); + }; + + const renderSearchResult = () => { + if (!searched) return null; + + if (searchedGroup) { + return ( + + + 搜索结果 + + {renderGroupItem({ item: searchedGroup })} + + ); + } + + if (!searching) { + return ( + + 暂无搜索结果,请检查群ID后重试 + + ); + } + + return null; + }; + return ( @@ -100,23 +223,25 @@ const JoinGroupScreen: React.FC = () => { - 搜索群聊(群ID) + + 搜索群聊(群ID) + {searching ? ( @@ -126,58 +251,49 @@ const JoinGroupScreen: React.FC = () => { - {group && ( - - - - - {group.name} - - - {!!group.description && ( - - {group.description} - - )} - - - 成员 {group.member_count}/{group.max_members} - - - {getJoinTypeText(group.join_type)} - - - - - 群号:{formatGroupNo(group.id)} - - - - 复制 - - - - {joining ? ( - - ) : ( - <> - - 申请入群 - - )} - - - )} + {/* 搜索结果 */} + {renderSearchResult()} - {searched && !group && !searching && ( - - 暂无搜索结果,请检查群ID后重试 + {/* 群组列表 */} + + + 推荐群组 - )} + String(item.id)} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={renderEmptyList} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多 + + + ) : groups.length > 0 ? ( + + 没有更多群组了 + + ) : null + } + showsVerticalScrollIndicator={false} + /> + ); @@ -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, }, }); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 943538f..918e8f7 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -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 = () => { - {isLoading ? ( + {isLoading && conversations.length === 0 ? ( @@ -901,6 +923,8 @@ export const MessageListScreen: React.FC = () => { ]} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} refreshControl={ { tintColor={colors.primary.main} /> } + ListFooterComponent={ + loadingMore ? ( + + + 加载中... + + ) : 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', diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index f7ddef1..bef0104 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -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([]); 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(); @@ -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 {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -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={ void }> = ({ onBack {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -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={ > { + try { + const response = await api.get>( + `/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> { + try { + const response = await api.get>( + `/comments/${commentId}/replies/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取评论回复列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出评论服务实例 diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 147aab8..330193d 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -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> { + try { + const response = await api.get>('/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> { + try { + const response = await api.get>( + `/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> { + try { + const response = await api.get>( + `/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的方法(将逐步废弃) ==================== /** diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 15f57ae..27a8722 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -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> { + try { + const response = await api.get>( + '/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> { + try { + const response = await api.get>( + `/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> { + try { + const response = await api.get>( + '/messages/system/cursor', + { params } + ); + return response.data; + } catch (error) { + console.error('获取系统消息列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + // ==================== 兼容旧API的方法(将逐步废弃) ==================== /** diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 2062e2e..f808b70 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -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> { return this.getNotifications(page, pageSize, 'mention'); } + + // ==================== 游标分页方法 ==================== + + /** + * 获取通知列表(游标分页) + * GET /api/v1/notifications/cursor + * @param params 游标分页请求参数 + */ + async getNotificationsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + '/notifications/cursor', + { params } + ); + return response.data; + } catch (error) { + console.error('获取通知列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出通知服务实例 diff --git a/src/services/postService.ts b/src/services/postService.ts index 32e1ecb..f4ece35 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -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> { + try { + const response = await api.get>('/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> { + try { + const response = await api.get>('/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> { + try { + const response = await api.get>( + `/users/${userId}/posts/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取用户帖子列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出帖子服务实例 diff --git a/src/types/dto.ts b/src/types/dto.ts index bf6efb3..ab43a3c 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -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 { + /** 数据项列表 */ + items: T[]; + /** 下一页游标 */ + next_cursor: string | null; + /** 上一页游标 */ + prev_cursor: string | null; + /** 是否有更多数据 */ + has_more: boolean; +} + // 设备Token响应 export interface DeviceTokenResponse { id: number;