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

281 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天详情页 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 });
},
});