Files
danyang-match/miniapp/pages/chat/chat.ts
T
Codex 687a156350 fix(chat): 会话/消息响应契约对齐小程序,修复聊天页字段断链
## 问题(三处真实断链)
后端 chat 模块字段名与小程序契约不一致,但自动化测试此前未覆盖,故一直未被发现:

1. 会话 DTO 用 otherUserId/otherDisplayName/otherAvatarUrl,
   而前端(types/index.d.ts + pages/chat/chat.ts + components/conversation-item)
   只读 targetUserId/targetDisplayName/targetAvatarUrl。
   → chat.ts 取不到对方 userId(无兜底,落到空串),
     导致"查看资料 / 拉黑 / 举报"点击后提示"暂无更多信息"并失效。
2. 消息响应只给 createdAt(ISO 字符串),前端读 Message.sentAt: number(epoch 毫秒)。
   → chat.ts 时间分隔 ts 恒为 0,消息时间分隔与时间文案不显示。
3. 会话对象缺 relationStatus,前端 conversation-item 依赖它做灰显与"已拉黑"提示。

## 裁决依据
按"以文件修改时间为准"的裁决统一到前端约定:
- 前端 types/index.d.ts 被 86 个页面引用,是声明式契约
- pages/chat/chat.ts(09-04)是最新的真实消费者
- targetUserId 已是项目内既有惯例(ActivityMatchItemDTO)
- admin-web 不消费 /conversations,无第二方受影响

## 变更
- ConversationDTO:字段改名 target*;新增 relationStatus;lastMessageAt 改 epoch 毫秒
- ChatService.toConversationDTO:同步映射;relationStatus 按 Block > unmatched > mutual_match > neutral 推导
- Constants:新增 RELATION_STATUS_NEUTRAL / MUTUAL_MATCH / UNMATCHED(不硬编码)
- MessageResponse:新增 sentAt(epoch 毫秒)
- miniapp/utils/datetime.js(新增):收敛时间格式化,
  补上模板依赖但全仓从未计算的 lastMessageAtText
- messageStore:会话进入 store 时统一装饰 lastMessageAtText
- chat.ts:_formatTime 改为复用共享工具;移除已失效的 otherDisplayName 兜底

## 验证
- 新增 ChatContractJsonTest(4 例)锁定字段名、sentAt 数值类型、
  并断言 other*/status 旧字段名不再出现
- 后端 294 tests, 0 failures, 0 errors(原 290 + 新增 4)
- 小程序 tsc --noEmit 通过

遗留(未在本轮处理):平台尚未实现头像 URL 解析(user_photos 只存 fileKey),
targetAvatarUrl 保持 null,前端 ui-avatar 回退昵称首字,不影响展示。
2026-09-15 22:45:46 +08:00

281 lines
9.2 KiB
TypeScript
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(TypeScript)
*
* 不变量(AGENTS.md):
* - 第 3 条:Mutual Match 后才能聊天
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false
* - 第 6 条:单向活动意向不泄露
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { messageStore, appStore, blockUser } from '../../stores';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { messageService } from '../../services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } from '../../constants';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { formatMessageTime } from '../../utils/datetime';
import type { Message } from '../../types';
/** 超过该间隔则显示时间分隔(5 分钟) */
const TIME_GAP_MS = 5 * 60 * 1000;
interface ChatData {
conversationId: string;
otherDisplayName: string;
otherUserId: string;
otherAvatarUrl: string;
canSend: boolean;
messages: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }>;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
sending: boolean;
inputText: string;
scrollIntoView: string;
statusBarHeight: number;
navBarHeight: number;
showDangerDialog: boolean;
dangerAction: string;
connectionState: string;
}
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: Record<string, string | undefined>) {
const conversationId = options.id || '';
if (!conversationId) {
appStore.toast('会话不存在');
wx.navigateBack();
return;
}
try {
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
this.setData({
statusBarHeight: (info as any).statusBarHeight || 20,
navBarHeight: (info as any).platform === 'android' ? 48 : 44,
});
} catch { /* 默认值 */ }
this.setData({ conversationId });
this._unsubConn = messageStore.bindToPage(this, { connectionState: 'connectionState' });
this._unsubList = messageStore.subscribe((state: any) => this._syncFromStore(state));
this._unsubRealtime = messageStore.onMessage(conversationId, (msg: Message) => {
if (msg.bubbleType === 'other') {
messageStore.markConversationRead(conversationId).catch(() => {});
}
});
messageStore.openConversation(conversationId).catch((err: unknown) => {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
});
this.loadConversationMeta();
},
onUnload() {
this._unsubConn && this._unsubConn();
this._unsubList && this._unsubList();
this._unsubRealtime && this._unsubRealtime();
if (this.data.conversationId) {
messageStore.closeConversation();
}
},
async loadConversationMeta() {
try {
// @ts-ignore
const conv = await messageService.getConversation(this.data.conversationId);
if (conv) {
this.setData({
otherDisplayName: (conv as any).targetDisplayName || '用户',
otherUserId: (conv as any).targetUserId || '',
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
canSend: (conv as any).canSendMessage !== false,
});
}
} catch (err: unknown) {
const local = (messageStore.state as any).conversations?.find(
(c: any) => 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 instanceof Error ? err.message : '加载失败');
}
}
},
_syncFromStore(state: any) {
const cid = this.data.conversationId;
const rawList: Message[] = state.messagesByConversation?.[cid] || [];
const hasMore = state.hasMoreByConversation?.[cid] !== false;
const loading = state.conversationsLoading;
const messages = this._formatMessageList(rawList);
const patch: Partial<ChatData> = { 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: Message[]) {
const otherAvatar = this.data.otherAvatarUrl;
const result: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }> = [];
let lastTs = 0;
for (let i = 0; i < list.length; i++) {
const m = list[i];
const ts = (m as any).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: number | string): string {
// 复用 utils/datetime,避免与会话列表各写一份时间格式化
return formatMessageTime(ts as any);
},
_scrollToLast(list: typeof this.data.messages) {
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: any) {
this.setData({ inputText: (e as any).detail.value });
},
async onSend() {
const { inputText, conversationId, sending, canSend } = this.data;
if (!canSend || !inputText.trim() || sending) return;
this.setData({ sending: true, inputText: '' });
try {
// @ts-ignore
await messageStore.sendMessage(conversationId, inputText.trim());
} catch (err: unknown) {
const code = (err as any)?.code;
if (code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
appStore.toast('双方互相喜欢后才能开始聊天');
} else {
appStore.toastError(err instanceof Error ? err.message : '发送失败');
}
} finally {
this.setData({ sending: false });
}
},
onRetrySend(e: any) {
const messageId = (e.currentTarget.dataset as any).msgId as string | undefined;
if (!messageId) return;
// @ts-ignore
messageStore.retrySend(this.data.conversationId, messageId).catch((err: unknown) => {
appStore.toastError(err instanceof Error ? err.message : '重发失败');
});
},
async onScrollToUpper() {
const { conversationId, hasMore, loadingMore } = this.data;
if (!hasMore || loadingMore) return;
const first = this.data.messages[0];
this._loadingMore = true;
this._preserveMsgId = first?.messageId || null;
this.setData({ loadingMore: true });
try {
// @ts-ignore
await messageStore.loadMoreMessages(conversationId);
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
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: WechatMiniprogram.CustomEvent) {
const action = (e.detail as any)?.action as string | undefined;
const { otherUserId } = this.data;
this.setData({ showDangerDialog: false });
if (action !== DANGER_ACTION.BLOCK) return;
try {
appStore.showLoading('处理中…');
await blockUser(otherUserId, '用户主动拉黑');
appStore.hideLoading();
this.setData({ canSend: false });
appStore.toastSuccess('已拉黑');
} catch (err: unknown) {
appStore.hideLoading();
appStore.toastError(err instanceof Error ? err.message : '操作失败');
}
},
onCancelDanger() {
this.setData({ showDangerDialog: false });
},
_unsubConn: (() => {}) as () => void,
_unsubList: (() => {}) as () => void,
_unsubRealtime: (() => {}) as () => void,
_loadingMore: false,
_preserveMsgId: null as string | null,
});