From 5c81795d399a0434bda795e0ecc78f775b038931 Mon Sep 17 00:00:00 2001 From: lan Date: Sat, 6 Jun 2026 13:10:08 +0800 Subject: [PATCH 1/3] fix(message): improve message synchronization and reliability Refactor the messaging subsystem to enhance data consistency, prevent race conditions during WebSocket reconnection, and ensure accurate unread counts. - **WebSocket Reliability**: Implement `client_msg_id` for precise message ACK matching, preventing incorrect pending message resolution in high-frequency scenarios. - **Sync Logic**: Update `WSMessageHandler` and `MessageManager` to use a bootstrapping state, ensuring buffered SSE events are flushed only after the initial synchronization is complete. - **State Consistency**: - Introduce atomic unread count increments to prevent lost updates. - Implement conditional rollback for optimistic read receipts, ensuring that failed API calls do not overwrite newer, valid read states. - **Resource Management**: Add reference counting to `useMessages` hook to prevent premature clearing of loading states during rapid conversation switching or React StrictMode double-invocations. - **Data Integrity**: Update `UserCacheService` to re-read the latest message list before applying sender info enrichment, ensuring new messages arriving during the async process are correctly processed. --- src/services/core/wsService.ts | 80 ++++++++++++++----- src/stores/message/MessageManager.ts | 6 +- src/stores/message/hooks.ts | 25 ++++-- .../message/services/MessageSyncService.ts | 3 +- .../message/services/ReadReceiptManager.ts | 38 ++++++--- .../message/services/UserCacheService.ts | 15 +++- .../message/services/WSMessageHandler.ts | 24 ++++-- src/stores/message/store.ts | 51 ++++++++++++ 8 files changed, 195 insertions(+), 47 deletions(-) diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index 04392e4..e6deb02 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -357,7 +357,7 @@ class WebSocketService { this.markActivity(); this.startHeartbeat(); this.connectionHandlers.forEach(h => h()); - + // 发送队列中的消息 this.flushPendingMessages(); }; @@ -776,10 +776,19 @@ class WebSocketService { } private handleMessageSent(payload: any): void { - // 找到对应的发送请求并resolve - const pendingMsg = this.pendingMessages.find(p => - p.type === 'chat' && p.payload.conversation_id === payload.conversation_id - ); + // 使用后端回带的 client_msg_id 精确匹配单条消息 + // 避免同会话连续发送时,第一条 ACK 误清掉第二条的 pending 记录 + const clientId = payload?.client_msg_id; + let pendingMsg: PendingMessage | undefined; + if (clientId) { + pendingMsg = this.pendingMessages.find(p => p.id === clientId); + } + // 兜底:旧版后端不回带 client_msg_id 时,按 conversation_id + 类型匹配最旧一条 + if (!pendingMsg) { + pendingMsg = this.pendingMessages.find(p => + p.type === 'chat' && p.payload.conversation_id === payload.conversation_id + ); + } if (pendingMsg) { pendingMsg.resolve(payload); this.removePendingMessage(pendingMsg.id); @@ -787,9 +796,16 @@ class WebSocketService { } private handleMessageRecalled(payload: any): void { - const pendingMsg = this.pendingMessages.find(p => - p.type === 'recall' && p.payload.message_id === payload.message_id - ); + const clientId = payload?.client_msg_id; + let pendingMsg: PendingMessage | undefined; + if (clientId) { + pendingMsg = this.pendingMessages.find(p => p.id === clientId); + } + if (!pendingMsg) { + pendingMsg = this.pendingMessages.find(p => + p.type === 'recall' && p.payload.message_id === payload.message_id + ); + } if (pendingMsg) { pendingMsg.resolve(payload); this.removePendingMessage(pendingMsg.id); @@ -880,16 +896,18 @@ class WebSocketService { } const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`; + // 携带 client_msg_id:用于服务器回 ACK / message_sent 时精确匹配单条消息 + const payloadWithId = { ...payload, client_msg_id: messageId }; const message = { type, - payload, + payload: payloadWithId, }; // 添加到待处理队列 const pendingMsg: PendingMessage = { id: messageId, type, - payload, + payload: payloadWithId, resolve, reject, timestamp: Date.now(), @@ -929,6 +947,11 @@ class WebSocketService { } // 刷新待发送消息队列 + // 过期判定: + // - 单条 sendViaWebSocket 自带 10s 超时,正常情况下 pending 消息不会超过这么久 + // - 重连后链路刚恢复时,距离"刚入队"的消息一律视为有效(避免把刚刚挂起的消息误清) + // - 兜底:超过 STALE_PENDING_MS(60s)一定视为过期丢弃 + private static readonly STALE_PENDING_MS = 60000; private flushPendingMessages(): void { if (this.pendingMessages.length === 0) return; @@ -938,7 +961,9 @@ class WebSocketService { // 分离过期和有效的消息 for (const msg of this.pendingMessages) { - if (now - msg.timestamp > 30000) { + const ageMs = now - msg.timestamp; + const isStale = ageMs > WebSocketService.STALE_PENDING_MS; + if (isStale) { expiredMessages.push(msg); } else { validMessages.push(msg); @@ -950,13 +975,32 @@ class WebSocketService { msg.reject(new Error('Message expired')); }); - // 重新发送有效消息 - this.pendingMessages = []; - validMessages.forEach(msg => { - this.sendViaWebSocket(msg.type, msg.payload) - .then(msg.resolve) - .catch(msg.reject); - }); + // 重新发送有效消息:直接复用原 client_msg_id,否则重连后服务端 ACK 无法回带到原 pending 记录 + // 注意:不能调 sendViaWebSocket(会重新生成 id 并入队),必须直接 ws.send + this.pendingMessages = validMessages; + for (let i = 0; i < validMessages.length; i++) { + const msg = validMessages[i]; + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + // 链路断开,剩余消息留在队列等下次重连 + break; + } + try { + this.ws.send(JSON.stringify({ type: msg.type, payload: msg.payload })); + } catch { + // ws.send 抛异常时 reject 并移出队列,避免无限残留 + msg.reject(new Error('WebSocket send failed on flush')); + this.removePendingMessage(msg.id); + continue; + } + // 重置 10s 超时 + setTimeout(() => { + const index = this.pendingMessages.findIndex(p => p.id === msg.id); + if (index >= 0) { + this.pendingMessages[index].reject(new Error('Message timeout')); + this.removePendingMessage(msg.id); + } + }, 10000); + } } private removePendingMessage(id: string): void { diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index be9f678..ea8a5b1 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -154,10 +154,10 @@ class MessageManager { useMessageStore.getState().setInitialized(true); - // 处理缓冲的 SSE 事件 - await this.wsHandler.flushBufferedSSEEvents(); - + // 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush + // 避免 flush 期间新到的事件被压入 buffer 后无人消费 this.wsHandler.setBootstrapping(false); + await this.wsHandler.flushBufferedSSEEvents(); if (useMessageStore.getState().currentConversationId) { await this.fetchMessages(useMessageStore.getState().currentConversationId!); diff --git a/src/stores/message/hooks.ts b/src/stores/message/hooks.ts index d949c59..ace59b8 100644 --- a/src/stores/message/hooks.ts +++ b/src/stores/message/hooks.ts @@ -100,9 +100,14 @@ interface UseMessagesReturn { * * 核心修复: * 1. useFocusEffect:屏幕重新获得焦点时强制同步消息,解决"需要退出重进才能看到新消息"的问题 - * 2. 清理 isLoadingMessages:组件卸载时清除残留的 loading 锁,防止下次进入被阻断 + * 2. 引用计数清理 isLoadingMessages:仅在最后一个订阅者卸载时清除 loading 锁, + * 避免 StrictMode 双调用或快速切换会话时,第一次 fetch 还没完成就被清锁 * 3. 重入时 forceSync:相同 conversationId 重复进入时强制拉取最新消息 */ + +// 记录每个 conversationId 上的活跃订阅数,仅在归零时清锁 +const loadingLockRefCount: Map = new Map(); + export function useMessages(conversationId: string | null): UseMessagesReturn { const normalizedConversationId = conversationId ? String(conversationId) : null; @@ -128,6 +133,10 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { return; } + // 引用计数 +1 + const prevCount = loadingLockRefCount.get(normalizedConversationId) ?? 0; + loadingLockRefCount.set(normalizedConversationId, prevCount + 1); + lastActivatedAtRef.current = Date.now(); const isReentry = lastActivatedIdRef.current === normalizedConversationId; @@ -141,11 +150,17 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { lastActivatedIdRef.current = normalizedConversationId; return () => { - // 清理活动会话 + 清除残留的 loading 锁 - if (messageManager.getActiveConversation() === normalizedConversationId) { - messageManager.setActiveConversation(null); + // 引用计数 -1,仅在最后一个订阅者卸载时清活动会话 + 清 loading 锁 + const nextCount = (loadingLockRefCount.get(normalizedConversationId) ?? 1) - 1; + if (nextCount <= 0) { + loadingLockRefCount.delete(normalizedConversationId); + if (messageManager.getActiveConversation() === normalizedConversationId) { + messageManager.setActiveConversation(null); + } + useMessageStore.getState().setLoadingMessages(normalizedConversationId, false); + } else { + loadingLockRefCount.set(normalizedConversationId, nextCount); } - useMessageStore.getState().setLoadingMessages(normalizedConversationId, false); }; }, [normalizedConversationId]); diff --git a/src/stores/message/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index 366b651..1c80137 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -197,9 +197,10 @@ export class MessageSyncService implements IMessageSyncService { /** * 获取会话消息(增量同步)— 去重入口 + * key 包含 force 维度,强制刷新会跳过非强制的 in-flight 合并 */ async fetchMessages(conversationId: string, afterSeq?: number, force?: boolean): Promise { - const key = `${conversationId}:${afterSeq ?? 'all'}`; + const key = `${conversationId}:${afterSeq ?? 'all'}:${force ? 'force' : 'normal'}`; const existing = this.inflightFetches.get(key); if (existing) return existing; diff --git a/src/stores/message/services/ReadReceiptManager.ts b/src/stores/message/services/ReadReceiptManager.ts index 5e18605..f9e6d0d 100644 --- a/src/stores/message/services/ReadReceiptManager.ts +++ b/src/stores/message/services/ReadReceiptManager.ts @@ -30,7 +30,7 @@ export class ReadReceiptManager implements IReadReceiptManager { const normalizedId = normalizeConversationId(conversationId); const store = useMessageStore.getState(); const conversation = store.getConversation(normalizedId); - + if (!conversation) { console.warn('[ReadReceiptManager] 会话不存在:', normalizedId); return; @@ -53,6 +53,9 @@ export class ReadReceiptManager implements IReadReceiptManager { this.readStateVersion++; const currentVersion = this.readStateVersion; + // 记录本次写入的 my_last_read_seq,用于失败时条件回滚 + const writtenReadSeq = Math.max(Number(conversation.my_last_read_seq || 0), seq); + // 1. 标记此会话有进行中的已读请求 this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), @@ -64,7 +67,7 @@ export class ReadReceiptManager implements IReadReceiptManager { const updatedConv: ConversationResponse = { ...conversation, unread_count: 0, - my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq), + my_last_read_seq: writtenReadSeq, }; const currentUnread = store.getUnreadCount(); @@ -79,11 +82,11 @@ export class ReadReceiptManager implements IReadReceiptManager { await messageService.markAsRead(normalizedId, seq); } catch (error) { console.error('[ReadReceiptManager] 标记已读API失败:', error); - - // 失败时回滚状态 - store.updateConversation(conversation); - store.setUnreadCount(currentUnread.total, currentUnread.system); - + + // 条件回滚(含 totalUnreadCount):仅在 my_last_read_seq 仍是本次写入值时回滚, + // 避免覆盖并发 markAsRead 已写入的更大读游标或更小未读 + store.rollbackReadIfUnchanged(normalizedId, writtenReadSeq, prevUnreadCount); + // API 失败时立即清除保护 this.pendingReadMap.delete(normalizedId); return; @@ -137,6 +140,9 @@ export class ReadReceiptManager implements IReadReceiptManager { }); store.setConversationsWithUnread(newConversations, 0, currentSystem); + // 记录本次乐观写入的"目标快照",用于失败时条件回滚 + const optimisticSnapshot = new Map(newConversations); + try { // 标记系统消息已读 await messageService.markAllSystemMessagesRead(); @@ -155,8 +161,22 @@ export class ReadReceiptManager implements IReadReceiptManager { } } catch (error) { console.error('[ReadReceiptManager] 标记所有已读失败:', error); - // 回滚:恢复原始会话和未读数 - store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem); + // 条件回滚:仅在 conversations 的关键字段未被其他操作修改时还原, + // 避免覆盖期间到达的 markAsRead / WS 推送造成的新未读 + const current = useMessageStore.getState().conversations; + let stillMatches = current.size === optimisticSnapshot.size; + if (stillMatches) { + for (const [id, conv] of optimisticSnapshot.entries()) { + const cur = current.get(id); + if (!cur || cur.unread_count !== conv.unread_count || cur.my_last_read_seq !== conv.my_last_read_seq) { + stillMatches = false; + break; + } + } + } + if (stillMatches) { + store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem); + } } } diff --git a/src/stores/message/services/UserCacheService.ts b/src/stores/message/services/UserCacheService.ts index 9b95cb1..8c0f774 100644 --- a/src/stores/message/services/UserCacheService.ts +++ b/src/stores/message/services/UserCacheService.ts @@ -6,6 +6,7 @@ import type { MessageResponse, UserDTO } from '../../../types/dto'; import { api } from '@/services/core'; import { userCacheRepository } from '@/database'; +import { useMessageStore } from '../store'; import type { IUserCacheService } from '../types'; export class UserCacheService implements IUserCacheService { @@ -61,16 +62,20 @@ export class UserCacheService implements IUserCacheService { /** * 批量异步填充消息的 sender 信息 - * 填充完成后调用 notifyUpdate 更新内存并通知订阅者 + * 注意:fetch 期间新消息可能已到达,回调内必须重读最新 messages 列表, + * 否则新消息的 sender 字段不会被填充。 */ enrichMessagesWithSenderInfo( conversationId: string, - messages: MessageResponse[], + _messages: MessageResponse[], notifyUpdate: (conversationId: string, messages: MessageResponse[]) => void ): void { + // 取一次最新列表作为本次填充目标 + const latestMessages = useMessageStore.getState().getMessages(conversationId); + // 收集所有需要查询的唯一 sender_id(排除系统用户) const senderIds = [...new Set( - messages + latestMessages .filter(m => m.sender_id && m.sender_id !== '10000' && !m.sender) .map(m => m.sender_id) )]; @@ -87,8 +92,10 @@ export class UserCacheService implements IUserCacheService { if (senderMap.size === 0) return; + // 重新读取最新消息列表(期间可能又有新消息到达) + const currentMessages = useMessageStore.getState().getMessages(conversationId); let changed = false; - const updated = messages.map(m => { + const updated = currentMessages.map(m => { if (!m.sender && m.sender_id && senderMap.has(m.sender_id)) { changed = true; return { ...m, sender: senderMap.get(m.sender_id) }; diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts index 67ec2cc..ee17cfa 100644 --- a/src/stores/message/services/WSMessageHandler.ts +++ b/src/stores/message/services/WSMessageHandler.ts @@ -212,10 +212,13 @@ export class WSMessageHandler implements IWSMessageHandler { /** * 统一同步入口 — 节流 + 去重 * onConnect 和 sync_required 共用此入口,防止并发触发重复同步 + * 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发 */ private async triggerSync(source: string): Promise { + if (this.isBootstrapping) return; + const now = Date.now(); - // 最小间隔节流 + // 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步 if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return; if (this.syncInProgress) return; @@ -246,14 +249,22 @@ export class WSMessageHandler implements IWSMessageHandler { /** * 初始化完成后,处理缓冲的 SSE 事件 + * 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖) + * 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费 */ async flushBufferedSSEEvents(): Promise { + let lastChatLen = -1; + let lastReadLen = -1; for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) { - const hasBuffered = this.bufferedChatMessages.length > 0 || this.bufferedReadReceipts.length > 0; - if (!hasBuffered) return; - const chatEvents = this.bufferedChatMessages; const readEvents = this.bufferedReadReceipts; + // 上一轮长度未变且都已清空,说明已稳定 + if (chatEvents.length === lastChatLen && readEvents.length === lastReadLen) { + return; + } + lastChatLen = chatEvents.length; + lastReadLen = readEvents.length; + // 立即清空 buffer;flush 期间新到的事件将走正常处理路径(isBootstrapping 已是 false) this.bufferedChatMessages = []; this.bufferedReadReceipts = []; @@ -528,9 +539,8 @@ export class WSMessageHandler implements IWSMessageHandler { } private incrementSystemUnread(): void { - const store = useMessageStore.getState(); - const currentUnread = store.getUnreadCount(); - store.setUnreadCount(currentUnread.total + 1, currentUnread.system + 1); + // 原子递增:避免外部 read-modify-write 造成丢计数 + useMessageStore.getState().incrementSystemUnreadAtomic(); } private markMessageAsRecalled(conversationId: string, messageId: string): void { diff --git a/src/stores/message/store.ts b/src/stores/message/store.ts index 00a1252..cfaf840 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -88,6 +88,10 @@ export interface MessageActions { removeMessage: (conversationId: string, messageId: string) => void; patchMessages: (conversationId: string, patches: Map>) => void; setUnreadCount: (total: number, system: number) => void; + /** + * 原子递增系统未读数:避免外部 RMW 造成丢失 + */ + incrementSystemUnreadAtomic: () => void; setLastSystemMessageAt: (time: string | null) => void; setSSEConnected: (connected: boolean) => void; setCurrentConversation: (conversationId: string | null) => void; @@ -107,6 +111,16 @@ export interface MessageActions { ) => void; incrementUnreadAtomic: (conversationId: string) => void; + /** + * 原子回滚乐观已读:仅在当前会话的 my_last_read_seq 仍等于 expectedReadSeq 时才回滚 + * 避免覆盖后续并发 markAsRead 已更新的更大值 + */ + rollbackReadIfUnchanged: ( + conversationId: string, + expectedReadSeq: number, + prevUnreadCount: number, + ) => void; + // 重置 reset: () => void; } @@ -393,6 +407,13 @@ export const useMessageStore = create((set, get) => ({ set({ totalUnreadCount: total, systemUnreadCount: system }); }, + incrementSystemUnreadAtomic: () => { + set(state => ({ + totalUnreadCount: state.totalUnreadCount + 1, + systemUnreadCount: state.systemUnreadCount + 1, + })); + }, + setLastSystemMessageAt: (time: string | null) => { set({ lastSystemMessageAt: time }); if (time) { @@ -506,6 +527,36 @@ export const useMessageStore = create((set, get) => ({ }); }, + rollbackReadIfUnchanged: ( + conversationId: string, + expectedReadSeq: number, + prevUnreadCount: number, + ) => { + const normalizedId = normalizeConversationId(conversationId); + set(state => { + const conversation = state.conversations.get(normalizedId); + if (!conversation) return state; + // 仅当本地乐观已读游标仍是本次写入的值时,才回滚未读 + // 否则说明已被更新的 markAsRead 覆盖,不应回滚 + if (Number(conversation.my_last_read_seq || 0) !== expectedReadSeq) { + return state; + } + const newConversations = new Map(state.conversations); + newConversations.set(normalizedId, { + ...conversation, + unread_count: prevUnreadCount, + }); + const conversationList = sortConversationList(newConversations); + // 同步回滚 totalUnreadCount:加回本次扣减的未读数 + const unreadDelta = prevUnreadCount - (conversation.unread_count || 0); + return { + conversations: newConversations, + conversationList, + totalUnreadCount: state.totalUnreadCount + unreadDelta, + }; + }); + }, + // ==================== 重置 ==================== reset: () => { From b15e0c0b0b323f2b3b906f9d6ad40fa46d9459b6 Mon Sep 17 00:00:00 2001 From: lan Date: Sun, 7 Jun 2026 00:40:35 +0800 Subject: [PATCH 2/3] feat(editor): implement deferred image uploading and pending state management Refactor the image handling workflow to decouple image selection from the upload process. This improves user experience by allowing users to continue composing posts or trades while images are queued for upload, and increases reliability by using a centralized pending image utility. - **Deferred Uploads**: Replaced immediate image uploads in `CreatePostScreen`, `PostDetailScreen`, and `CreateTradeScreen` with a "pending" state. Images are now uploaded in bulk during the final submission phase. - **BlockEditor Enhancements**: - Updated `useBlockEditor` to support asynchronous batch uploading of all pending images via a new `uploadPendingImages` method. - Removed inline uploading overlays in favor of a more robust state-driven approach. - Improved `BlockEditorHandle` to expose block retrieval and batch upload capabilities. - **API Layer Improvements**: - Migrated native image uploads from standard `FormData` to `expo-file-system`'s multipart upload to resolve compatibility issues with recent React Native versions. - Maintained `fetch` + `Blob` logic for web platform compatibility. - **New Utilities**: Introduced `src/utils/pendingImages.ts` to manage the lifecycle of local (pending) vs. remote (uploaded) images across the application. --- package-lock.json | 1 - .../business/BlockEditor/BlockEditor.tsx | 6 +- .../business/BlockEditor/ImageBlockView.tsx | 14 +- .../business/BlockEditor/blockEditorTypes.ts | 2 +- .../business/BlockEditor/blocksToSegments.ts | 1 - .../business/BlockEditor/useBlockEditor.ts | 103 ++++----- src/screens/create/CreatePostScreen.tsx | 196 +++++++++--------- src/screens/home/PostDetailScreen.tsx | 78 +++---- src/screens/trade/CreateTradeScreen.tsx | 81 +++----- src/services/core/api.ts | 73 ++++--- src/utils/pendingImages.ts | 89 ++++++++ 11 files changed, 331 insertions(+), 313 deletions(-) create mode 100644 src/utils/pendingImages.ts diff --git a/package-lock.json b/package-lock.json index 0d5f78f..b5bd908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "with_you", "version": "0.0.2", - "hasInstallScript": true, "dependencies": { "@expo/ui": "^56.0.15", "@expo/vector-icons": "^15.1.1", diff --git a/src/components/business/BlockEditor/BlockEditor.tsx b/src/components/business/BlockEditor/BlockEditor.tsx index db9cde5..c1ef82a 100644 --- a/src/components/business/BlockEditor/BlockEditor.tsx +++ b/src/components/business/BlockEditor/BlockEditor.tsx @@ -14,7 +14,8 @@ export interface BlockEditorHandle { insertTextAtCursor: (text: string) => void; getSegments: () => MessageSegment[]; getContent: () => string; - hasUploadingImages: () => boolean; + getBlocks: () => EditorBlock[]; + uploadPendingImages: () => Promise; focus: () => void; } @@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef( insertTextAtCursor: editor.insertTextAtCursor, getSegments: editor.getSegments, getContent: editor.getContent, - hasUploadingImages: editor.hasUploadingImages, + getBlocks: editor.getBlocks, + uploadPendingImages: editor.uploadPendingImages, focus: () => { const firstTextBlock = editor.blocks.find(b => b.type === 'text'); if (firstTextBlock) { diff --git a/src/components/business/BlockEditor/ImageBlockView.tsx b/src/components/business/BlockEditor/ImageBlockView.tsx index aff2f39..a462102 100644 --- a/src/components/business/BlockEditor/ImageBlockView.tsx +++ b/src/components/business/BlockEditor/ImageBlockView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -41,11 +41,6 @@ const ImageBlockView: React.FC = ({ block, onRemove, style cachePolicy="memory-disk" transition={200} /> - {block.uploading && ( - - - - )} onRemove(block.id)} @@ -66,13 +61,6 @@ const styles = StyleSheet.create({ position: 'relative', alignItems: 'center', }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.md, - }, removeButton: { position: 'absolute', top: 8, diff --git a/src/components/business/BlockEditor/blockEditorTypes.ts b/src/components/business/BlockEditor/blockEditorTypes.ts index 240071e..66e1bd8 100644 --- a/src/components/business/BlockEditor/blockEditorTypes.ts +++ b/src/components/business/BlockEditor/blockEditorTypes.ts @@ -12,7 +12,7 @@ export interface ImageBlock { type: 'image'; localUri: string; remoteUrl?: string; - uploading: boolean; + mimeType?: string; width?: number; height?: number; } diff --git a/src/components/business/BlockEditor/blocksToSegments.ts b/src/components/business/BlockEditor/blocksToSegments.ts index 2c9d0e0..c93b7b8 100644 --- a/src/components/business/BlockEditor/blocksToSegments.ts +++ b/src/components/business/BlockEditor/blocksToSegments.ts @@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] { type: 'image', localUri: seg.data.url, remoteUrl: seg.data.url, - uploading: false, width: seg.data.width, height: seg.data.height, }; diff --git a/src/components/business/BlockEditor/useBlockEditor.ts b/src/components/business/BlockEditor/useBlockEditor.ts index 62eef8e..592ec09 100644 --- a/src/components/business/BlockEditor/useBlockEditor.ts +++ b/src/components/business/BlockEditor/useBlockEditor.ts @@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const [activeBlockId, setActiveBlockId] = useState(null); const inputRefs = useRef>(new Map()); const blockCursors = useRef>(new Map()); + const blocksRef = useRef(blocks); + blocksRef.current = blocks; const updateTextBlock = useCallback( (blockId: string, text: string, mentions: Map) => { @@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { }); }, [focusBlock]); - const uploadImageByLocalUri = useCallback( - async (asset: ImagePicker.ImagePickerAsset) => { - try { - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); + const uploadPendingImages = useCallback(async (): Promise => { + let allSuccess = true; - if (uploadResult) { - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { - ...b, - remoteUrl: uploadResult.url, - uploading: false, - width: uploadResult.width ?? b.width, - height: uploadResult.height ?? b.height, - } - : b - ) - ); - } else { - // Upload failed - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { ...b, uploading: false } - : b - ) - ); - Alert.alert('上传失败', '图片上传失败,请重试'); + const uploadBlock = async () => { + const currentBlocks = blocksRef.current; + const pendingBlocks = currentBlocks.filter( + (b): b is ImageBlock => b.type === 'image' && !b.remoteUrl, + ); + + for (const block of pendingBlocks) { + try { + const uploadResult = await uploadService.uploadImage({ + uri: block.localUri, + type: block.mimeType || 'image/jpeg', + }); + + if (uploadResult) { + setBlocks(prev => + prev.map(b => + b.id === block.id && b.type === 'image' + ? { + ...b, + remoteUrl: uploadResult.url, + width: uploadResult.width ?? b.width, + height: uploadResult.height ?? b.height, + } + : b, + ), + ); + } else { + allSuccess = false; + } + } catch { + allSuccess = false; } - } catch { - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { ...b, uploading: false } - : b - ) - ); } - }, - [], - ); + }; + + await uploadBlock(); + return allSuccess; + }, []); const insertImagesFromAssets = useCallback( (assets: ImagePicker.ImagePickerAsset[]) => { @@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { ? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length) : 0; - // Create image block const imageBlockId = generateBlockId(); const imageBlock: ImageBlock = { id: imageBlockId, type: 'image', localUri: asset.uri, - uploading: true, + mimeType: asset.mimeType, width: asset.width, height: asset.height, }; @@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos); const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length); - // Create new text block for text after cursor const newTextBlockId = generateBlockId(); const newTextBlock: TextBlock = { id: newTextBlockId, @@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { activeMentions: mentionsAfter, }; - // Update current block to text before cursor updated[insertAfterIndex] = { ...activeBlock, text: textBefore, activeMentions: mentionsBefore, }; - // Insert image block and new text block updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock); - // Update index for next image insertion insertAfterIndex = insertAfterIndex + 2; - // Focus the new text block after the image setActiveBlockId(newTextBlockId); requestAnimationFrame(() => focusBlock(newTextBlockId)); } else { - // Insert after the current block (which is an image) updated.splice(insertAfterIndex + 1, 0, imageBlock); insertAfterIndex = insertAfterIndex + 1; } @@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { return updated; }); - - // Start async uploads - for (const asset of assets) { - uploadImageByLocalUri(asset); - } }, - [activeBlockId, focusBlock, uploadImageByLocalUri], + [activeBlockId, focusBlock], ); const insertImage = useCallback(async () => { @@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]); const getContent = useCallback(() => blocksToContent(blocks), [blocks]); - const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]); + const getBlocks = useCallback(() => blocks, [blocks]); const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => { if (ref) { @@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { focusBlock, getSegments, getContent, - hasUploadingImages, + getBlocks, + uploadPendingImages, registerInputRef, updateCursor, }; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 6ee176d..9dbda2e 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -38,15 +38,26 @@ import { import { Text, ResponsiveContainer } from '../../components/common'; import { channelService, postService, showPrompt, voteService } from '../../services'; import { ApiError } from '@/services/core'; -import { uploadService } from '@/services/upload'; import VoteEditor from '../../components/business/VoteEditor'; import PostMentionInput from '../../components/business/PostMentionInput'; import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor'; import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments'; -import type { EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes'; +import { + generateBlockId, + type EditorBlock, + type ImageBlock, +} from '../../components/business/BlockEditor/blockEditorTypes'; import { useResponsive, useResponsiveValue } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; import { MessageSegment } from '../../types'; +import { + type PendingOrRemoteImage, + type RemoteImage, + generatePendingImageId, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; // Props 接口 interface CreatePostScreenProps { @@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC = (props) => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [segments, setSegments] = useState([]); - const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); + // 图片状态:pending 表示本地待上传,remote 表示已是服务器 URL(编辑模式下的已存在图片) + const [images, setImages] = useState([]); const [channelOptions, setChannelOptions] = useState([]); const [selectedChannelId, setSelectedChannelId] = useState(null); const [showChannelPicker, setShowChannelPicker] = useState(false); @@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC = (props) => { } setTitle(existingPost.title || ''); setContent(existingPost.content || ''); - setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false }))); + setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url }))); // 自动判断长文模式:segments 中包含图片块即为长文帖子 const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image'); @@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC = (props) => { loadPostForEdit(); }, [isEditMode, editPostID, router]); - // 选择图片(普通模式) + // 选择图片(普通模式)—— 仅选择,不立即上传 const handlePickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC = (props) => { }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset)); setImages(prev => [...prev, ...newImages]); - - // 上传图片 - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }; - // 拍照 + // 拍照 —— 仅添加,不立即上传 const handleTakePhoto = async () => { const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); @@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC = (props) => { if (!result.canceled && result.assets[0]) { const asset = result.assets[0]; - setImages(prev => [...prev, { uri: asset.uri, uploading: true }]); - - // 上传图片 - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } + setImages(prev => [...prev, makePendingImageFromAsset(asset)]); } }; // 删除图片 const handleRemoveImage = (index: number) => { - setImages(images.filter((_, i) => i !== index)); + setImages(prev => prev.filter((_, i) => i !== index)); }; // 选择频道(单选,可再次点击取消) @@ -350,26 +319,44 @@ export const CreatePostScreen: React.FC = (props) => { // 切换长文/非长文模式时保留数据 const handleToggleLongPostMode = useCallback(() => { if (!isLongPostMode) { - // 非长文 -> 长文:将当前文本和图片转为 blocks - const segs: MessageSegment[] = []; + // 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks + const blocks: EditorBlock[] = []; if (content.trim()) { - segs.push({ type: 'text', data: { text: content } }); + blocks.push({ + id: generateBlockId(), + type: 'text', + text: content, + activeMentions: new Map(), + }); } for (const img of images) { - if (img.uri && !img.uploading) { - segs.push({ type: 'image', data: { url: img.uri } }); + const imageBlock: ImageBlock = img.kind === 'remote' + ? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url } + : { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType }; + blocks.push(imageBlock); + } + setInitialBlocks(blocks.length > 0 ? blocks : undefined); + } else { + // 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending) + const editorContent = blockEditorRef.current?.getContent() || ''; + const editorBlocks = blockEditorRef.current?.getBlocks() || []; + if (editorContent) setContent(editorContent); + + const editorImages: PendingOrRemoteImage[] = []; + for (const block of editorBlocks) { + if (block.type !== 'image') continue; + if (block.remoteUrl) { + editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl }); + } else { + editorImages.push({ + id: generatePendingImageId(), + kind: 'pending', + localUri: block.localUri, + mimeType: block.mimeType, + }); } } - setInitialBlocks(segs.length > 0 ? segmentsToBlocks(segs) : undefined); - } else { - // 长文 -> 非长文:从 BlockEditor 提取文本和图片 - const editorContent = blockEditorRef.current?.getContent() || ''; - const editorSegments = blockEditorRef.current?.getSegments() || []; - if (editorContent) setContent(editorContent); - const editorImages = editorSegments - .filter((s): s is Extract => s.type === 'image') - .map(s => ({ uri: s.data.url, uploading: false })); - if (editorImages.length > 0) setImages(editorImages); + setImages(editorImages); } setIsLongPostMode(prev => !prev); }, [isLongPostMode, content, images]); @@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC = (props) => { return; } - // 检查是否有图片正在上传 - if (isLongPostMode) { - if (blockEditorRef.current?.hasUploadingImages()) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; - } - } else { - const uploadingImages = images.filter(img => img.uploading); - if (uploadingImages.length > 0) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; - } - } + // 检查 BlockEditor 中是否有正在上传的图片 + // (已无此状态,发布时会统一上传,无需检查) setPosting(true); try { + // 普通模式下,发布前上传所有待上传的图片 + let uploadedImageUrls: string[] = []; + if (!isLongPostMode && images.length > 0) { + const uploadResult = await uploadAllPendingImages(images, (id, url) => { + setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + setPosting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; + } + + // 长文模式下,发布前上传 BlockEditor 中所有 pending 图片 + if (isLongPostMode) { + const uploadOk = await blockEditorRef.current?.uploadPendingImages(); + if (uploadOk === false) { + setPosting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + } + if (isVotePost) { - const imageUrls = images.map(img => img.uri); // 验证投票选项 const validOptions = voteOptions.filter(opt => opt.trim() !== ''); if (validOptions.length < 2) { @@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC = (props) => { await voteService.createVotePost({ title: title.trim(), content: content.trim(), - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, vote_options: validOptions.map(opt => ({ content: opt })), }); @@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC = (props) => { } } else { // 普通模式 - const imageUrls = images.map(img => img.uri); if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { title: title.trim(), content: content.trim(), segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, }); if (!updated) { throw new Error('更新帖子失败'); @@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC = (props) => { title: title.trim(), content: content.trim(), segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 }); @@ -547,27 +545,25 @@ export const CreatePostScreen: React.FC = (props) => { {images.map((img, index) => ( - - {img.uploading && ( - - - - )} + handleRemoveImage(index)} - disabled={img.uploading} hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} > @@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) { width: '100%', height: '100%', }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - }, removeImageButton: { position: 'absolute', top: 4, diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 0d880b2..a95de51 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -38,12 +38,18 @@ import { import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/auth'; -import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { postService, commentService, authService, showPrompt, voteService } from '../../services'; import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; +import { + type PendingOrRemoteImage, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; import { handleError } from '@/services/core'; import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime'; import * as hrefs from '../../navigation/hrefs'; @@ -251,7 +257,7 @@ export const PostDetailScreen: React.FC = () => { const [keyboardHeight, setKeyboardHeight] = useState(0); const [isDeleting, setIsDeleting] = useState(false); // 评论图片相关状态 - const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]); + const [commentImages, setCommentImages] = useState([]); const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [isFollowingMe, setIsFollowingMe] = useState(false); @@ -688,31 +694,8 @@ export const PostDetailScreen: React.FC = () => { }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ci')); setCommentImages(prev => [...prev, ...newImages]); - - // 上传图片 - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setCommentImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setCommentImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }; @@ -727,11 +710,17 @@ export const PostDetailScreen: React.FC = () => { const hasContent = commentText.trim() || commentImages.length > 0; if (!hasContent || !post) return; - // 检查是否有图片正在上传 - const uploadingImages = commentImages.filter(img => img.uploading); - if (uploadingImages.length > 0) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; + // 上传所有 pending 图片 + let uploadedImageUrls: string[] = []; + if (commentImages.length > 0) { + const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => { + setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; } const tempId = `new-${Date.now()}`; @@ -741,9 +730,6 @@ export const PostDetailScreen: React.FC = () => { // 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论) const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null; - // 获取已上传图片的URL列表 - const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri); - // 先创建临时评论显示在列表中 const tempComment: Comment = { id: tempId, @@ -1501,13 +1487,12 @@ export const PostDetailScreen: React.FC = () => { {commentImages.length > 0 && ( {commentImages.map((image, index) => ( - - - {image.uploading && ( - - - - )} + + handleRemoveCommentImage(index)} @@ -2185,17 +2170,6 @@ function createPostDetailStyles(colors: AppColors) { height: 60, borderRadius: borderRadius.sm, }, - uploadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.4)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.sm, - }, removeImageButton: { position: 'absolute', top: -6, diff --git a/src/screens/trade/CreateTradeScreen.tsx b/src/screens/trade/CreateTradeScreen.tsx index 58b7236..0621260 100644 --- a/src/screens/trade/CreateTradeScreen.tsx +++ b/src/screens/trade/CreateTradeScreen.tsx @@ -24,15 +24,21 @@ import { ActivityIndicator, } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; -import { Loading } from '../../components/common'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useAppColors, type AppColors } from '../../theme'; import { Text } from '../../components/common'; import { tradeService } from '../../services/trade/tradeService'; -import { uploadService } from '../../services'; import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade'; import { ALL_TRADE_CATEGORIES, ALL_TRADE_CONDITIONS } from '../../types/trade'; +import { + type PendingOrRemoteImage, + type RemoteImage, + generatePendingImageId, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; interface CreateTradeScreenProps { onClose: () => void; @@ -258,13 +264,6 @@ function createStyles(colors: AppColors) { justifyContent: 'center', backgroundColor: colors.background.default, }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0,0,0,0.3)', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 14, - }, // 提交按钮 submitBtn: { @@ -323,8 +322,8 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS const [condition, setCondition] = useState( editItem?.condition as TradeCondition | undefined ); - const [images, setImages] = useState<{ uri: string; uploading: boolean }[]>( - editItem?.images?.map(img => ({ uri: img.url, uploading: false })) || [] + const [images, setImages] = useState( + editItem?.images?.map((img): RemoteImage => ({ id: generatePendingImageId('ti'), kind: 'remote', url: img.url })) || [] ); const [submitting, setSubmitting] = useState(false); @@ -352,19 +351,29 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS Alert.alert('提示', '请输入标题'); return; } - if (images.some(img => img.uploading)) { - Alert.alert('提示', '请等待图片上传完成'); - return; - } setSubmitting(true); try { + // 上传所有 pending 图片 + let uploadedImageUrls: string[] = []; + if (images.length > 0) { + const uploadResult = await uploadAllPendingImages(images, (id, url) => { + setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + setSubmitting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; + } + const payload = { trade_type: tradeType, category, title: title.trim(), content: content.trim() || title.trim(), - images: images.map(img => img.uri), + images: uploadedImageUrls, price: price ? parseFloat(price) : null, condition, }; @@ -403,37 +412,16 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ti')); setImages(prev => [...prev, ...newImages]); - - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }, [images.length]); - const removeImage = useCallback((uri: string) => { - setImages(prev => prev.filter(img => img.uri !== uri)); + const removeImage = useCallback((id: string) => { + setImages(prev => prev.filter(img => img.id !== id)); }, []); - const canSubmit = title.trim().length > 0 && !submitting && !images.some(i => i.uploading); + const canSubmit = title.trim().length > 0 && !submitting; return ( @@ -558,14 +546,9 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS 图片 ({images.length}/9) {images.map((img) => ( - - - {img.uploading && ( - - - - )} - removeImage(img.uri)}> + + + removeImage(img.id)}> diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 732d0db..7279fb0 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -7,6 +7,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import Constants from 'expo-constants'; +import { File as FSFile, UploadType } from 'expo-file-system'; import { Platform } from 'react-native'; import { eventBus } from '@/core/events/EventBus'; @@ -437,35 +438,10 @@ class ApiClient { file: { uri: string; name: string; type: string }, additionalData?: Record ): Promise> { - const formData = new FormData(); - - // 添加文件(使用 image 字段名) - // Web 端需要 append Blob/File;原生端可以直接传 { uri, name, type }。 - if (Platform.OS === 'web') { - const fileResponse = await fetch(file.uri); - const blob = await fileResponse.blob(); - const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' }); - formData.append('image', uploadFile); - } else { - formData.append('image', { - uri: file.uri, - name: file.name, - type: file.type, - } as any); - } - - // 添加额外数据 - if (additionalData) { - Object.keys(additionalData).forEach(key => { - formData.append(key, additionalData[key]); - }); - } - // 获取 token 并检查是否需要刷新 const token = await this.getToken(); - const headers: HeadersInit = {}; + const headers: Record = {}; if (token) { - // 检查 token 是否快过期 if (this.isTokenExpiringSoon(token)) { const refreshed = await this.refreshToken(); if (refreshed) { @@ -479,14 +455,45 @@ class ApiClient { } } - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers, - body: formData, - }); + let data: ApiResponse; + + if (Platform.OS === 'web') { + // Web 端:fetch + Blob/File + FormData + const formData = new FormData(); + const fileResponse = await fetch(file.uri); + const blob = await fileResponse.blob(); + const uploadFile = new File([blob], file.name, { + type: file.type || blob.type || 'application/octet-stream', + }); + formData.append('image', uploadFile); + + if (additionalData) { + Object.keys(additionalData).forEach(key => { + formData.append(key, additionalData[key]); + }); + } + + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers, + body: formData, + }); + data = await response.json(); + } else { + // 原生端:使用 expo-file-system 原生 multipart 上传 + // RN 0.85 的 FormData/Blob 不支持从 URI 创建文件 part + const fsFile = new FSFile(file.uri); + const result = await fsFile.upload(`${this.baseUrl}${path}`, { + httpMethod: 'POST', + uploadType: UploadType.MULTIPART, + fieldName: 'image', + mimeType: file.type, + headers, + parameters: additionalData, + }); + data = JSON.parse(result.body) as ApiResponse; + } - const data: ApiResponse = await response.json(); - if (data.code !== 0) { throw new ApiError(data.code, data.message); } diff --git a/src/utils/pendingImages.ts b/src/utils/pendingImages.ts new file mode 100644 index 0000000..b4cc002 --- /dev/null +++ b/src/utils/pendingImages.ts @@ -0,0 +1,89 @@ +/** + * 通用「待上传图片」抽象 + * + * 用于发帖、评论、发布商品等场景: + * - 用户选择图片时仅保存本地 URI(pending) + * - 提交时统一上传,失败可重试已上传的不会丢失 + */ + +import type * as ImagePicker from 'expo-image-picker'; +import { uploadService } from '@/services/upload'; + +export type PendingImage = { + id: string; + kind: 'pending'; + localUri: string; + mimeType?: string; +}; + +export type RemoteImage = { + id: string; + kind: 'remote'; + url: string; +}; + +export type PendingOrRemoteImage = PendingImage | RemoteImage; + +let _imgIdCounter = 0; +export const generatePendingImageId = (prefix = 'img'): string => + `${prefix}-${++_imgIdCounter}-${Date.now()}`; + +/** 从 ImagePicker 选择结果构造 pending 图片 */ +export const makePendingImageFromAsset = ( + asset: ImagePicker.ImagePickerAsset, + idPrefix?: string, +): PendingImage => ({ + id: generatePendingImageId(idPrefix), + kind: 'pending', + localUri: asset.uri, + mimeType: asset.mimeType, +}); + +/** 获取图片的展示 URI(pending 用本地,remote 用远程) */ +export const getImageDisplayUri = (img: PendingOrRemoteImage): string => + img.kind === 'pending' ? img.localUri : img.url; + +/** 转换为 remote 图片(保持 id 不变) */ +export const toRemoteImage = (id: string, url: string): RemoteImage => ({ + id, + kind: 'remote', + url, +}); + +export type UploadAllResult = + | { success: true; urls: string[] } + | { success: false; urls: string[]; failedId: string }; + +/** + * 批量上传 pending 图片,已是 remote 的会跳过。 + * 顺序串行执行,遇到失败立即中断并返回。 + * + * @param images 图片列表 + * @param onItemUploaded 单张图片上传成功后的回调(用于增量更新 state, + * 外部可据此将该 id 的图片标记为 remote 以便重试时跳过) + */ +export const uploadAllPendingImages = async ( + images: PendingOrRemoteImage[], + onItemUploaded?: (id: string, url: string) => void, +): Promise => { + const urls: string[] = []; + + for (const img of images) { + if (img.kind === 'remote') { + urls.push(img.url); + continue; + } + const result = await uploadService.uploadImage({ + uri: img.localUri, + type: img.mimeType || 'image/jpeg', + }); + if (result) { + urls.push(result.url); + onItemUploaded?.(img.id, result.url); + } else { + return { success: false, urls, failedId: img.id }; + } + } + + return { success: true, urls }; +}; From afbbee337d2a86326829d1dc005ed3c14467d1e4 Mon Sep 17 00:00:00 2001 From: lan Date: Sun, 7 Jun 2026 10:37:19 +0800 Subject: [PATCH 3/3] feat(ui): improve navigation, component props, and chat logic - update `app.json` with splash screen configuration - implement tab navigation redirection in `TabsLayout` for apps and profile tabs - update `HighlightText` prop usage from `style` to `highlightStyle` in `PostCard` and `PostContentRenderer` - fix chat message segment construction to avoid empty text segments when sending only images - refine UI styling by removing unnecessary shadows and adjusting `UserScreen` header visibility - improve `HomeScreen` scroll behavior by disabling animation on FlashList scroll-to-top --- app.json | 9 +++++++- app/(app)/(tabs)/_layout.tsx | 21 ++++++++++++++++++- src/components/business/PostCard/PostCard.tsx | 8 +++---- .../business/PostContentRenderer.tsx | 6 +++--- src/components/business/UserProfileHeader.tsx | 1 - src/screens/home/HomeScreen.tsx | 2 +- .../message/components/ChatScreen/styles.ts | 5 ----- .../components/ChatScreen/useChatScreen.ts | 5 ++++- src/screens/profile/UserScreen.tsx | 2 +- 9 files changed, 41 insertions(+), 18 deletions(-) diff --git a/app.json b/app.json index e42885b..c633dce 100644 --- a/app.json +++ b/app.json @@ -155,7 +155,14 @@ ], "./plugins/withHuaweiPush", "expo-callkit-telecom", - "expo-splash-screen", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ], "expo-status-bar" ], "extra": { diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index d4e2844..e6260fd 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -1,6 +1,6 @@ import { useMemo, useCallback } from 'react'; import { Platform, Pressable, useWindowDimensions } from 'react-native'; -import { Tabs, usePathname } from 'expo-router'; +import { Tabs, usePathname, useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { PressableProps } from 'react-native'; @@ -28,6 +28,7 @@ export default function TabsLayout() { const { width } = useWindowDimensions(); const insets = useSafeAreaInsets(); const pathname = usePathname(); + const router = useRouter(); const unreadCount = useTotalUnreadCount(); const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress); @@ -41,6 +42,18 @@ export default function TabsLayout() { } }, [pathname, triggerHomeTabPress]); + const handleAppsTabPress = useCallback(() => { + if (pathname.startsWith('/apps/')) { + router.replace('/apps'); + } + }, [pathname, router]); + + const handleProfileTabPress = useCallback(() => { + if (pathname.startsWith('/profile/')) { + router.replace('/profile'); + } + }, [pathname, router]); + const tabBarStyle = useMemo(() => { if (hideTabBar) { return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; @@ -116,6 +129,9 @@ export default function TabsLayout() { /> ), }} + listeners={{ + tabPress: handleAppsTabPress, + }} /> ), }} + listeners={{ + tabPress: handleProfileTabPress, + }} /> ); diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 9e2a58c..35927c5 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -662,7 +662,7 @@ const PostCardInner: React.FC = (normalizedProps) => { ) : ( - {highlightKeyword ? : contentPreview} + {highlightKeyword ? : contentPreview} )} @@ -676,7 +676,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {highlightKeyword ? : post.title} + {highlightKeyword ? : post.title} )} @@ -748,7 +748,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {highlightKeyword ? : post.title} + {highlightKeyword ? : post.title} )} @@ -758,7 +758,7 @@ const PostCardInner: React.FC = (normalizedProps) => { style={styles.content} numberOfLines={isExpanded ? undefined : contentNumberOfLines} > - {highlightKeyword ? : content} + {highlightKeyword ? : content} {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> diff --git a/src/components/business/PostContentRenderer.tsx b/src/components/business/PostContentRenderer.tsx index 9f6d106..bc5d74a 100644 --- a/src/components/business/PostContentRenderer.tsx +++ b/src/components/business/PostContentRenderer.tsx @@ -61,7 +61,7 @@ const PostContentRenderer: React.FC = ({ return ( {highlightKeyword && content ? ( - + ) : ( content || '' )} @@ -85,7 +85,7 @@ const PostContentRenderer: React.FC = ({ elements.push( {highlightKeyword ? ( - + ) : ( text )} @@ -125,7 +125,7 @@ const PostContentRenderer: React.FC = ({ return ( {highlightKeyword && content ? ( - + ) : ( content || '' )} diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index 52e7417..8ec38e0 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -551,7 +551,6 @@ function createUserProfileHeaderStyles(colors: AppColors) { minWidth: 160, backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, - ...shadows.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.divider, overflow: 'hidden', diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index ba0cfe4..a027b7a 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -364,7 +364,7 @@ export const HomeScreen: React.FC = () => { useEffect(() => { if (homeTabPressCount > 0) { // 滚动 FlashList 到顶部 - flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); // 滚动 ScrollView 到顶部(网格模式) scrollViewRef.current?.scrollTo({ y: 0, animated: true }); // 重置底部 Tab 栏状态 diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 54c7925..609d4c7 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { backgroundColor: 'rgba(255, 255, 255, 0.96)', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(0, 0, 0, 0.08)', - shadowColor: colors.chat.shadow, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.12, - shadowRadius: 6, - elevation: 4, }, jumpToLatestFabText: { fontSize: 13, diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 5e5a231..f64ab6a 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -1104,7 +1104,10 @@ export const useChatScreen = (props?: ChatScreenProps) => { setSending(true); try { - const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)]; + // 有文本或回复时才构建文本段,纯图片时不塞空 text + const segments: MessageSegment[] = (trimmedText || replyingTo) + ? [...buildTextSegments(trimmedText, replyingTo)] + : []; for (const url of uploadedUrls) { segments.push({ type: 'image', diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index af8a6fb..89051dd 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => { );