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 回退昵称首字,不影响展示。
This commit is contained in:
+9
@@ -110,6 +110,14 @@ public class MessageController {
|
|||||||
private String content;
|
private String content;
|
||||||
private String fileId;
|
private String fileId;
|
||||||
private String status;
|
private String status;
|
||||||
|
/**
|
||||||
|
* 发送时间,epoch 毫秒。
|
||||||
|
*
|
||||||
|
* <p>对齐小程序 {@code types/index.d.ts} 的 {@code Message.sentAt: number}。
|
||||||
|
* 前端 {@code pages/chat/chat.ts} 依赖它计算消息时间分隔与展示,
|
||||||
|
* {@code stores/messageStore.js} 用它回填本地乐观消息,故必须提供。
|
||||||
|
*/
|
||||||
|
private Long sentAt;
|
||||||
private java.time.Instant createdAt;
|
private java.time.Instant createdAt;
|
||||||
private boolean mine;
|
private boolean mine;
|
||||||
private String bubbleType;
|
private String bubbleType;
|
||||||
@@ -126,6 +134,7 @@ public class MessageController {
|
|||||||
r.fileId = m.getFileId();
|
r.fileId = m.getFileId();
|
||||||
r.status = m.getStatus();
|
r.status = m.getStatus();
|
||||||
r.createdAt = m.getCreatedAt();
|
r.createdAt = m.getCreatedAt();
|
||||||
|
r.sentAt = m.getCreatedAt() == null ? null : m.getCreatedAt().toEpochMilli();
|
||||||
r.mine = isMine;
|
r.mine = isMine;
|
||||||
r.bubbleType = isMine ? "self" : "other";
|
r.bubbleType = isMine ? "self" : "other";
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -2,26 +2,47 @@ package com.danyang.match.platform.chat.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话列表/详情响应
|
* 会话列表/详情响应。
|
||||||
*
|
*
|
||||||
* 字段对齐小程序 types/index.ts 的 Conversation 契约:
|
* <p>契约权威来源:小程序 {@code miniapp/types/index.d.ts} 的 {@code Conversation} 接口
|
||||||
* conversationId / matchId / otherUserId / otherDisplayName / otherAvatarUrl
|
* (该文件被 86 个小程序页面共同引用,且 {@code pages/chat/chat.ts}、{@code components/conversation-item}
|
||||||
* / lastMessage / lastMessageAt / unreadCount / status / canSendMessage
|
* 均按此字段名消费)。历史上后端曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl}
|
||||||
|
* 这套字段名,与前端契约不一致,导致聊天页取不到对方 userId(资料页/拉黑/举报失效),
|
||||||
|
* 本次按"以最近修改时间为准"的裁决统一到前端约定。
|
||||||
|
*
|
||||||
|
* <p>字段对应关系:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code targetUserId} ← 原 otherUserId</li>
|
||||||
|
* <li>{@code targetDisplayName} ← 原 otherDisplayName</li>
|
||||||
|
* <li>{@code targetAvatarUrl} ← 原 otherAvatarUrl</li>
|
||||||
|
* <li>{@code relationStatus} ← 替代原 status(会话状态语义收敛为关系状态)</li>
|
||||||
|
* <li>{@code lastMessageAt} ← 由 Instant 改为 epoch 毫秒,对齐前端 {@code number}</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ConversationDTO {
|
public class ConversationDTO {
|
||||||
|
|
||||||
private String conversationId;
|
private String conversationId;
|
||||||
private String matchId;
|
private String matchId;
|
||||||
private String otherUserId;
|
private String targetUserId;
|
||||||
private String otherDisplayName;
|
private String targetDisplayName;
|
||||||
private String otherAvatarUrl;
|
/**
|
||||||
|
* 对方头像 URL。
|
||||||
|
*
|
||||||
|
* <p>注意:平台尚未实现头像 URL 解析——{@code user_photos} 只存 {@code fileKey},
|
||||||
|
* 对外 URL 需经对象存储签名后生成,目前后端无任何接口对外暴露头像。
|
||||||
|
* 前端 {@code ui-avatar} 对空值回退为昵称首字,故此处保持 null 不影响展示。
|
||||||
|
*/
|
||||||
|
private String targetAvatarUrl;
|
||||||
private String lastMessage;
|
private String lastMessage;
|
||||||
private Instant lastMessageAt;
|
/** 最后一条消息时间,epoch 毫秒。 */
|
||||||
|
private Long lastMessageAt;
|
||||||
private int unreadCount;
|
private int unreadCount;
|
||||||
private String status;
|
/**
|
||||||
|
* 与对方的关系状态,取值对齐小程序 RELATION_STATUS:
|
||||||
|
* neutral / liked / passed / mutual_match / blocked / unmatched。
|
||||||
|
*/
|
||||||
|
private String relationStatus;
|
||||||
private boolean canSendMessage;
|
private boolean canSendMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,8 +271,8 @@ public class ChatService {
|
|||||||
ConversationDTO dto = new ConversationDTO();
|
ConversationDTO dto = new ConversationDTO();
|
||||||
dto.setConversationId(conv.getId());
|
dto.setConversationId(conv.getId());
|
||||||
dto.setMatchId(conv.getMatchId());
|
dto.setMatchId(conv.getMatchId());
|
||||||
dto.setStatus(conv.getStatus());
|
// 时间统一为 epoch 毫秒,对齐小程序 Conversation.lastMessageAt: number
|
||||||
dto.setLastMessageAt(conv.getLastMessageAt());
|
dto.setLastMessageAt(conv.getLastMessageAt() == null ? null : conv.getLastMessageAt().toEpochMilli());
|
||||||
|
|
||||||
// 对方
|
// 对方
|
||||||
String peerId = null;
|
String peerId = null;
|
||||||
@@ -283,10 +283,10 @@ public class ChatService {
|
|||||||
matchAlive = Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())
|
matchAlive = Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())
|
||||||
|| Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(match.getStatus());
|
|| Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(match.getStatus());
|
||||||
}
|
}
|
||||||
dto.setOtherUserId(peerId);
|
dto.setTargetUserId(peerId);
|
||||||
|
|
||||||
if (peerId != null) {
|
if (peerId != null) {
|
||||||
userProfileMapper.findByUserId(peerId).ifPresent(p -> dto.setOtherDisplayName(p.getDisplayName()));
|
userProfileMapper.findByUserId(peerId).ifPresent(p -> dto.setTargetDisplayName(p.getDisplayName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未读数
|
// 未读数
|
||||||
@@ -309,6 +309,20 @@ public class ChatService {
|
|||||||
dto.setCanSendMessage(canSend && matchAlive && !blocked
|
dto.setCanSendMessage(canSend && matchAlive && !blocked
|
||||||
&& Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus()));
|
&& Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus()));
|
||||||
|
|
||||||
|
// 关系状态:Block 优先,其次看会话/Match 是否已解除
|
||||||
|
// 前端 components/conversation-item 依赖 relationStatus==='blocked' 做灰显与"已拉黑"提示
|
||||||
|
String relationStatus;
|
||||||
|
if (blocked) {
|
||||||
|
relationStatus = Constants.RELATION_TYPE_BLOCKED;
|
||||||
|
} else if (Constants.CONVERSATION_STATUS_UNMATCHED.equals(conv.getStatus())) {
|
||||||
|
relationStatus = Constants.RELATION_STATUS_UNMATCHED;
|
||||||
|
} else if (matchAlive) {
|
||||||
|
relationStatus = Constants.RELATION_STATUS_MUTUAL_MATCH;
|
||||||
|
} else {
|
||||||
|
relationStatus = Constants.RELATION_STATUS_NEUTRAL;
|
||||||
|
}
|
||||||
|
dto.setRelationStatus(relationStatus);
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public final class Constants {
|
|||||||
public static final String RELATION_TYPE_PASSED = "passed";
|
public static final String RELATION_TYPE_PASSED = "passed";
|
||||||
public static final String RELATION_TYPE_BLOCKED = "blocked";
|
public static final String RELATION_TYPE_BLOCKED = "blocked";
|
||||||
|
|
||||||
|
// 关系状态(对前端的统一表述,对齐小程序 RELATION_STATUS)
|
||||||
|
public static final String RELATION_STATUS_NEUTRAL = "neutral";
|
||||||
|
public static final String RELATION_STATUS_MUTUAL_MATCH = "mutual_match";
|
||||||
|
public static final String RELATION_STATUS_UNMATCHED = "unmatched";
|
||||||
|
|
||||||
// Match Source
|
// Match Source
|
||||||
public static final String MATCH_SOURCE_USER_LIKE = "user_like";
|
public static final String MATCH_SOURCE_USER_LIKE = "user_like";
|
||||||
public static final String MATCH_SOURCE_ACTIVITY_MUTUAL = "activity_mutual";
|
public static final String MATCH_SOURCE_ACTIVITY_MUTUAL = "activity_mutual";
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.danyang.match.platform.chat;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.chat.controller.MessageController;
|
||||||
|
import com.danyang.match.platform.chat.dto.ConversationDTO;
|
||||||
|
import com.danyang.match.platform.chat.entity.Message;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天接口 JSON 契约测试。
|
||||||
|
*
|
||||||
|
* <p>契约权威来源:小程序 {@code miniapp/types/index.d.ts}。该文件被 86 个小程序页面引用,
|
||||||
|
* 且 {@code pages/chat/chat.ts}、{@code components/conversation-item} 按其中的字段名直接取值。
|
||||||
|
*
|
||||||
|
* <p>历史缺陷(本测试防回归):
|
||||||
|
* <ol>
|
||||||
|
* <li>后端会话 DTO 曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl},
|
||||||
|
* 而前端只读 {@code targetUserId/targetDisplayName/targetAvatarUrl},
|
||||||
|
* 导致聊天页取不到对方 userId —— 资料页、拉黑、举报全部失效。</li>
|
||||||
|
* <li>后端消息响应只给 {@code createdAt}(ISO 字符串),而前端读
|
||||||
|
* {@code Message.sentAt: number}(epoch 毫秒),导致聊天页时间分隔恒为 0 而不显示。</li>
|
||||||
|
* <li>会话对象缺少 {@code relationStatus},前端 {@code conversation-item} 无法
|
||||||
|
* 按 {@code relationStatus === 'blocked'} 做灰显与"已拉黑"提示。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class ChatContractJsonTest {
|
||||||
|
|
||||||
|
/** 与 Spring Boot 应用上下文一致的 Jackson 配置(Instant 需要 JSR310 模块) */
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
private static final List<String> CONVERSATION_FIELDS = List.of(
|
||||||
|
"conversationId",
|
||||||
|
"matchId",
|
||||||
|
"targetUserId",
|
||||||
|
"targetDisplayName",
|
||||||
|
"targetAvatarUrl",
|
||||||
|
"lastMessage",
|
||||||
|
"lastMessageAt",
|
||||||
|
"unreadCount",
|
||||||
|
"relationStatus",
|
||||||
|
"canSendMessage");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("契约: 会话响应字段名与小程序 types/index.d.ts 完全一致")
|
||||||
|
void conversationFieldsMatchMiniappContract() throws Exception {
|
||||||
|
ConversationDTO dto = new ConversationDTO();
|
||||||
|
dto.setConversationId("conv-1");
|
||||||
|
dto.setTargetUserId("usr-peer");
|
||||||
|
dto.setTargetDisplayName("王女士");
|
||||||
|
dto.setLastMessage("周末有时间吗?");
|
||||||
|
dto.setLastMessageAt(Instant.parse("2026-09-15T10:00:00Z").toEpochMilli());
|
||||||
|
dto.setUnreadCount(2);
|
||||||
|
dto.setRelationStatus("mutual_match");
|
||||||
|
dto.setCanSendMessage(true);
|
||||||
|
|
||||||
|
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||||
|
|
||||||
|
for (String field : CONVERSATION_FIELDS) {
|
||||||
|
assertTrue(json.has(field), "会话响应缺少前端依赖的字段: " + field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("契约: 会话响应不得再出现 other* 旧字段名与 status 旧字段")
|
||||||
|
void conversationHasNoLegacyFieldNames() throws Exception {
|
||||||
|
ConversationDTO dto = new ConversationDTO();
|
||||||
|
dto.setConversationId("conv-1");
|
||||||
|
dto.setTargetUserId("usr-peer");
|
||||||
|
dto.setRelationStatus("blocked");
|
||||||
|
|
||||||
|
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||||
|
|
||||||
|
assertFalse(json.has("otherUserId"), "otherUserId 是废弃字段名,前端从不读取");
|
||||||
|
assertFalse(json.has("otherDisplayName"), "otherDisplayName 是废弃字段名");
|
||||||
|
assertFalse(json.has("otherAvatarUrl"), "otherAvatarUrl 是废弃字段名");
|
||||||
|
assertFalse(json.has("status"), "会话 status 已由 relationStatus 取代");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("契约: lastMessageAt 必须是 epoch 毫秒数值而非 ISO 字符串")
|
||||||
|
void lastMessageAtIsEpochMillis() throws Exception {
|
||||||
|
long epochMillis = Instant.parse("2026-09-15T10:00:00Z").toEpochMilli();
|
||||||
|
ConversationDTO dto = new ConversationDTO();
|
||||||
|
dto.setLastMessageAt(epochMillis);
|
||||||
|
|
||||||
|
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||||
|
|
||||||
|
assertTrue(json.get("lastMessageAt").isNumber(),
|
||||||
|
"前端 Conversation.lastMessageAt 声明为 number,后端必须给 epoch 毫秒");
|
||||||
|
assertEquals(epochMillis, json.get("lastMessageAt").asLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("契约: 消息响应必须提供 sentAt(epoch 毫秒),前端据此算时间分隔")
|
||||||
|
void messageResponseProvidesSentAt() throws Exception {
|
||||||
|
Instant created = Instant.parse("2026-09-15T10:00:00Z");
|
||||||
|
Message m = new Message();
|
||||||
|
m.setId("msg-1");
|
||||||
|
m.setConversationId("conv-1");
|
||||||
|
m.setSenderId("usr-peer");
|
||||||
|
m.setMessageType("text");
|
||||||
|
m.setContent("你好");
|
||||||
|
m.setStatus("sent");
|
||||||
|
m.setCreatedAt(created);
|
||||||
|
|
||||||
|
JsonNode json = mapper.readTree(
|
||||||
|
mapper.writeValueAsString(MessageController.MessageResponse.from(m, "usr-me")));
|
||||||
|
|
||||||
|
assertTrue(json.has("sentAt"), "消息响应缺少前端依赖的 sentAt");
|
||||||
|
assertTrue(json.get("sentAt").isNumber(), "sentAt 必须是 epoch 毫秒数值");
|
||||||
|
assertEquals(created.toEpochMilli(), json.get("sentAt").asLong());
|
||||||
|
assertEquals("other", json.get("bubbleType").asText(), "非本人消息 bubbleType 应为 other");
|
||||||
|
assertEquals("msg-1", json.get("messageId").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { messageStore, appStore, blockUser } from '../../stores';
|
|||||||
import { messageService } from '../../services';
|
import { messageService } from '../../services';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
import { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } from '../../constants';
|
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';
|
import type { Message } from '../../types';
|
||||||
|
|
||||||
/** 超过该间隔则显示时间分隔(5 分钟) */
|
/** 超过该间隔则显示时间分隔(5 分钟) */
|
||||||
@@ -105,7 +107,7 @@ Page({
|
|||||||
const conv = await messageService.getConversation(this.data.conversationId);
|
const conv = await messageService.getConversation(this.data.conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
this.setData({
|
this.setData({
|
||||||
otherDisplayName: (conv as any).targetDisplayName || (conv as any).otherDisplayName || '用户',
|
otherDisplayName: (conv as any).targetDisplayName || '用户',
|
||||||
otherUserId: (conv as any).targetUserId || '',
|
otherUserId: (conv as any).targetUserId || '',
|
||||||
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
|
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
|
||||||
canSend: (conv as any).canSendMessage !== false,
|
canSend: (conv as any).canSendMessage !== false,
|
||||||
@@ -169,12 +171,8 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
_formatTime(ts: number | string): string {
|
_formatTime(ts: number | string): string {
|
||||||
if (!ts) return '';
|
// 复用 utils/datetime,避免与会话列表各写一份时间格式化
|
||||||
const d = new Date(Number(ts));
|
return formatMessageTime(ts as any);
|
||||||
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: typeof this.data.messages) {
|
_scrollToLast(list: typeof this.data.messages) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
const BaseStore = require('./baseStore');
|
const BaseStore = require('./baseStore');
|
||||||
const { messageService } = require('../services');
|
const { messageService } = require('../services');
|
||||||
|
const { formatConversationTime } = require('../utils/datetime');
|
||||||
const { STORAGE_KEYS, MESSAGE_SEND_STATUS, CHAT_CONNECTION } = require('../constants');
|
const { STORAGE_KEYS, MESSAGE_SEND_STATUS, CHAT_CONNECTION } = require('../constants');
|
||||||
|
|
||||||
/** 单会话内存缓存上限(避免无限增长) */
|
/** 单会话内存缓存上限(避免无限增长) */
|
||||||
@@ -66,7 +67,7 @@ class MessageStore extends BaseStore {
|
|||||||
try {
|
try {
|
||||||
const result = await messageService.getConversations({ pageSize: 20 });
|
const result = await messageService.getConversations({ pageSize: 20 });
|
||||||
this.setState({
|
this.setState({
|
||||||
conversations: result.items || [],
|
conversations: this._decorateConversations(result.items),
|
||||||
conversationsCursor: result.nextCursor || null,
|
conversationsCursor: result.nextCursor || null,
|
||||||
conversationsHasMore: !!result.nextCursor,
|
conversationsHasMore: !!result.nextCursor,
|
||||||
conversationsLoading: false,
|
conversationsLoading: false,
|
||||||
@@ -89,7 +90,7 @@ class MessageStore extends BaseStore {
|
|||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
});
|
});
|
||||||
this.setState({
|
this.setState({
|
||||||
conversations: [...this.state.conversations, ...(result.items || [])],
|
conversations: [...this.state.conversations, ...this._decorateConversations(result.items)],
|
||||||
conversationsCursor: result.nextCursor || null,
|
conversationsCursor: result.nextCursor || null,
|
||||||
conversationsHasMore: !!result.nextCursor,
|
conversationsHasMore: !!result.nextCursor,
|
||||||
conversationsLoading: false,
|
conversationsLoading: false,
|
||||||
@@ -371,11 +372,30 @@ class MessageStore extends BaseStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为会话补上模板所需的展示字段。
|
||||||
|
*
|
||||||
|
* components/conversation-item/index.wxml 读取 `lastMessageAtText`,
|
||||||
|
* 该字段由服务端不产出(后端只给 epoch 毫秒的 lastMessageAt),
|
||||||
|
* 故在进入 store 时统一格式化一次,避免每个页面各自实现。
|
||||||
|
*/
|
||||||
|
_decorateConversations(items) {
|
||||||
|
return (items || []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
lastMessageAtText: formatConversationTime(c.lastMessageAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
_patchConversationLastMessage(conversationId, content, sentAt) {
|
_patchConversationLastMessage(conversationId, content, sentAt) {
|
||||||
this.setState({
|
this.setState({
|
||||||
conversations: this.state.conversations.map((c) =>
|
conversations: this.state.conversations.map((c) =>
|
||||||
c.conversationId === conversationId
|
c.conversationId === conversationId
|
||||||
? { ...c, lastMessage: content, lastMessageAt: sentAt }
|
? {
|
||||||
|
...c,
|
||||||
|
lastMessage: content,
|
||||||
|
lastMessageAt: sentAt,
|
||||||
|
lastMessageAtText: formatConversationTime(sentAt),
|
||||||
|
}
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 时间格式化工具
|
||||||
|
*
|
||||||
|
* 背景:会话列表模板(components/conversation-item/index.wxml)读取
|
||||||
|
* `conversation.lastMessageAtText` 做展示,但此前全仓无任何地方计算该字段,
|
||||||
|
* 导致会话列表时间恒为空;聊天页 pages/chat/chat.ts 又自带一份 _formatTime。
|
||||||
|
* 本模块把这两处时间格式化收敛到一处,避免重复实现。
|
||||||
|
*
|
||||||
|
* 约定:后端时间字段一律为 epoch 毫秒(number),与
|
||||||
|
* types/index.d.ts 的 `lastMessageAt?: number` / `Message.sentAt: number` 对齐。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 补零 */
|
||||||
|
function pad2(n) {
|
||||||
|
return n < 10 ? '0' + n : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化为 Date,非法输入返回 null */
|
||||||
|
function toDate(ts) {
|
||||||
|
if (ts === null || ts === undefined || ts === '') return null;
|
||||||
|
var d = ts instanceof Date ? ts : new Date(Number(ts));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一天 */
|
||||||
|
function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear()
|
||||||
|
&& a.getMonth() === b.getMonth()
|
||||||
|
&& a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话列表时间:刚刚 / HH:mm / 昨天 / M/D / YYYY/M/D
|
||||||
|
* @param {number|Date} ts
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatConversationTime(ts) {
|
||||||
|
var d = toDate(ts);
|
||||||
|
if (!d) return '';
|
||||||
|
var now = new Date();
|
||||||
|
var diff = now.getTime() - d.getTime();
|
||||||
|
if (diff >= 0 && diff < 60 * 1000) return '刚刚';
|
||||||
|
if (isSameDay(d, now)) return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||||
|
var yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
if (isSameDay(d, yesterday)) return '昨天';
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return (d.getMonth() + 1) + '/' + d.getDate();
|
||||||
|
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天气泡时间:今天 HH:mm(省略日期),否则 M/D HH:mm
|
||||||
|
* 与 pages/chat/chat.ts 原有逻辑保持一致。
|
||||||
|
* @param {number|Date} ts
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatMessageTime(ts) {
|
||||||
|
var d = toDate(ts);
|
||||||
|
if (!d) return '';
|
||||||
|
var now = new Date();
|
||||||
|
var hm = pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||||
|
if (isSameDay(d, now)) return hm;
|
||||||
|
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatConversationTime: formatConversationTime,
|
||||||
|
formatMessageTime: formatMessageTime,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user