/** * 聊天详情页 U24(重构:用 messageStore + message-bubble) * * 不变量(AGENTS.md): * - 第 3 条:Mutual Match 后才能聊天——sendMessage 若后端返回 MATCH_REQUIRED, * UI 给出引导文案;不在客户端做硬阻断,由后端守门。 * - 第 4 条:Block/Unmatch 后会话 canSendMessage=false,输入栏禁用并显示"已停用"。 * - 第 6 条:单向活动意向不泄露——本页只展示已建立的 Mutual 会话消息。 * - 第 7 条:普通工作人员不能查看私人聊天——本页仅服务用户端。 * - 第 9 条:PostgreSQL 是 Source of Truth——本地仅缓存最近活跃会话消息,冷启动从服务端拉取。 */ const { messageStore, appStore, blockUser } = require('../../stores'); const { messageService } = require('../../services'); const { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } = require('../../constants'); /** 超过该间隔则显示时间分隔(5 分钟) */ const TIME_GAP_MS = 5 * 60 * 1000; Page({ data: { conversationId: '', otherDisplayName: '', otherUserId: '', otherAvatarUrl: '', canSend: true, messages: [], hasMore: true, loading: false, loadingMore: false, sending: false, inputText: '', scrollIntoView: '', statusBarHeight: 20, navBarHeight: 44, showDangerDialog: false, dangerAction: DANGER_ACTION.BLOCK, connectionState: 'disconnected', }, onLoad(options) { const conversationId = options.id || ''; if (!conversationId) { appStore.toast('会话不存在'); wx.navigateBack(); return; } // 注入状态栏高度供 ui-nav-bar 使用 try { const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync(); this.setData({ statusBarHeight: info.statusBarHeight || 20, navBarHeight: info.platform === 'android' ? 48 : 44, }); } catch (e) { /* 使用默认值 */ } this.setData({ conversationId }); // 绑定 messageStore.connectionState 到页面 this._unsubConn = messageStore.bindToPage(this, { connectionState: 'connectionState', }); // 订阅消息列表 / 分页 / loading 变化 this._unsubList = messageStore.subscribe((state) => this._syncFromStore(state)); // 注册实时消息监听:收到对方消息立即标记已读(会话已处于前台) this._unsubRealtime = messageStore.onMessage(conversationId, (msg) => { if (msg.bubbleType === 'other') { messageStore.markConversationRead(conversationId).catch(() => {}); } }); // 打开会话:拉取最近消息 + 清未读(AGENTS 第 9 条,DB 为准) messageStore.openConversation(conversationId).catch((err) => appStore.toastError(err)); // 拉取会话元数据(displayName / canSend / targetUserId / 头像) this.loadConversationMeta(); }, onUnload() { this._unsubConn && this._unsubConn(); this._unsubList && this._unsubList(); this._unsubRealtime && this._unsubRealtime(); if (this.data.conversationId) { // 关闭会话:activeConversationId 置空,后续收到对方消息将累计未读 messageStore.closeConversation(); } }, /** 拉取会话元数据(canSend 以服务端为准) */ async loadConversationMeta() { try { const conv = await messageService.getConversation(this.data.conversationId); if (conv) { this.setData({ otherDisplayName: conv.targetDisplayName || conv.otherDisplayName || '用户', otherUserId: conv.targetUserId || '', otherAvatarUrl: conv.targetAvatarUrl || '', canSend: conv.canSendMessage !== false, }); } } catch (err) { // 兜底:从 store 会话列表查找 const local = messageStore.state.conversations.find( (c) => c.conversationId === this.data.conversationId, ); if (local) { this.setData({ otherDisplayName: local.targetDisplayName || '用户', otherUserId: local.targetUserId || '', otherAvatarUrl: local.targetAvatarUrl || '', canSend: local.canSendMessage !== false, }); } else { appStore.toastError(err); } } }, /** 把 store 状态同步到页面 */ _syncFromStore(state) { const cid = this.data.conversationId; const rawList = state.messagesByConversation[cid] || []; const hasMore = state.hasMoreByConversation[cid] !== false; const loading = state.conversationsLoading; const messages = this._formatMessageList(rawList); const patch = { messages, hasMore }; if (loading !== this.data.loading) patch.loading = loading; this.setData(patch); // 加载更早消息时,保持滚动位置(定位到加载前的第一条) if (this._loadingMore && this._preserveMsgId) { const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId); if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` }); this._loadingMore = false; this._preserveMsgId = null; } else { this._scrollToLast(messages); } }, /** 派生时间分隔 / 头像 / 是否显示头像 */ _formatMessageList(list) { const otherAvatar = this.data.otherAvatarUrl; const result = []; let lastTs = 0; for (let i = 0; i < list.length; i++) { const m = list[i]; const ts = m.sentAt || 0; const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS); result.push({ ...m, showTime, sentAtText: showTime ? this._formatTime(ts) : '', showAvatar: m.bubbleType === 'other', avatarUrl: m.bubbleType === 'other' ? otherAvatar : '', }); lastTs = ts; } return result; }, _formatTime(ts) { if (!ts) return ''; const d = new Date(ts); const now = new Date(); const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); if (d.toDateString() === now.toDateString()) return hm; return `${d.getMonth() + 1}/${d.getDate()} ${hm}`; }, _scrollToLast(list) { if (!list.length) return; const id = `msg-${list.length - 1}`; if (id !== this.data.scrollIntoView) { this.setData({ scrollIntoView: id }); } }, onBack() { const pages = getCurrentPages(); if (pages.length > 1) { wx.navigateBack(); } else { wx.switchTab({ url: ROUTE.TAB.MESSAGES }); } }, onInput(e) { this.setData({ inputText: e.detail.value }); }, async onSend() { const { inputText, conversationId, sending, canSend } = this.data; if (!canSend || !inputText.trim() || sending) return; this.setData({ sending: true, inputText: '' }); try { await messageStore.sendMessage(conversationId, inputText.trim()); } catch (err) { // MATCH_REQUIRED:双方互相喜欢后才能聊天(AGENTS 第 3 条,后端守门) if (err && err.code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) { appStore.toast('双方互相喜欢后才能开始聊天'); } else { appStore.toastError(err, '发送失败'); } } finally { this.setData({ sending: false }); } }, /** 重发失败消息 */ onRetrySend(e) { const messageId = e.currentTarget.dataset.msgId; if (!messageId) return; messageStore .retrySend(this.data.conversationId, messageId) .catch((err) => appStore.toastError(err, '重发失败')); }, /** 滚动到顶部:加载更早消息(保持滚动位置) */ async onScrollToUpper() { const { conversationId, hasMore, loadingMore } = this.data; if (!hasMore || loadingMore) return; const first = this.data.messages[0]; this._loadingMore = true; this._preserveMsgId = first ? first.messageId : null; this.setData({ loadingMore: true }); try { await messageStore.loadMoreMessages(conversationId); } catch (err) { appStore.toastError(err); this._loadingMore = false; this._preserveMsgId = null; } finally { this.setData({ loadingMore: false }); } }, /** 右上角更多:查看资料 / 举报 / 拉黑 */ onMore() { const { otherUserId } = this.data; if (!otherUserId) { appStore.toast('暂无更多信息'); return; } wx.showActionSheet({ itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'], success: (res) => { if (res.tapIndex === 0) { wx.navigateTo({ url: to.detail(otherUserId) }); } else if (res.tapIndex === 1) { wx.navigateTo({ url: to.report(otherUserId) }); } else if (res.tapIndex === 2) { this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK }); } }, }); }, /** 危险动作确认(拉黑) */ async onConfirmDanger(e) { const action = e.detail.action; const { otherUserId, conversationId } = this.data; this.setData({ showDangerDialog: false }); if (action !== DANGER_ACTION.BLOCK) return; try { appStore.showLoading('处理中…'); // 拉黑:触发 MatchStore + MessageStore 同步副作用(AGENTS 第 4 条即时停用) await blockUser(otherUserId, '用户主动拉黑'); appStore.hideLoading(); this.setData({ canSend: false }); appStore.toastSuccess('已拉黑'); } catch (err) { appStore.hideLoading(); appStore.toastError(err); } }, onCancelDanger() { this.setData({ showDangerDialog: false }); }, });