Files
danyang-match/miniapp/pages/chat/chat.js
T

281 lines
9.3 KiB
JavaScript
Raw Normal View History

2026-09-09 22:05:00 +08:00
/**
* 聊天详情页 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 });
},
});