YingXingAI/utils/chat.js

190 lines
6.6 KiB
JavaScript
Raw Normal View History

import dayjs from "dayjs";
/**
* 判断聊天列表中某条消息上方是否需要展示时间分隔
*
* 典型用法 `v-for` 渲染消息时根据相邻两条消息的时间差决定是否插入时间条
*
* 规则
* - 第一条消息始终展示时间
* - 其他消息与上一条消息时间差大于 `gapMs` 时展示
*
* 边界与兼容
* - `messageList` 不是数组 / 为空返回 `false`
* - `sendDate` 为空或无法解析为有效时间戳返回 `false`
*
* @param {Array<{ sendDate?: string }>} messageList - 消息数组按时间正序
* @param {number} index - 当前消息在数组中的下标
* @param {number} gapMs - 时间间隔阈值毫秒默认 30 分钟
* @returns {boolean} - 是否需要展示时间
*/
export function shouldShowTime(messageList, index, gapMs = 30 * 60 * 1000) {
if (!Array.isArray(messageList) || !messageList.length) return false;
if (index === 0) return true;
const current = messageList[index];
const prev = messageList[index - 1];
// `sendDate` 期望为 ISO 字符串或能被 Date 解析的字符串
const currentTime = new Date(current && current.sendDate).getTime();
const prevTime = new Date(prev && prev.sendDate).getTime();
if (Number.isNaN(currentTime) || Number.isNaN(prevTime)) return false;
return currentTime - prevTime > gapMs;
}
/**
* 将消息发送时间格式化为聊天页可展示的文本
*
* 规则与页面原逻辑一致
* - 60 秒内`刚刚`
* - 1 小时内`x分钟前`
* - 今天`今天 HH:mm`
* - 昨天`昨天 HH:mm`
* - 更早但在今年`MM月DD日 HH:mm`
* - 往年`YYYY年MM月DD日 HH:mm`
*
* 说明
* - 使用 `dayjs` 进行日期差计算与格式化避免手写边界处理
* - 若传入的 `sendDate` 不可解析dayjs 会返回 Invalid Date
* 此时 diff 结果可能不符合预期上层建议确保 sendDate 为后端返回的时间字符串
*
* @param {string} sendDate - 消息发送时间后端返回的时间字符串
* @returns {string} - 格式化后的展示文本
*/
export function formatChatShowTime(sendDate) {
const now = dayjs();
const msgTime = dayjs(sendDate);
if (now.diff(msgTime, "second") < 60) {
return "刚刚";
}
if (now.diff(msgTime, "hour") < 1) {
return `${now.diff(msgTime, "minute")}分钟前`;
}
// 使用 startOf('day') 抹平时分秒,方便按“天”比较
const today = dayjs().startOf("day");
const msgDay = dayjs(sendDate).startOf("day");
const dayDiff = today.diff(msgDay, "day");
if (dayDiff === 0) {
return `今天 ${msgTime.format("HH:mm")}`;
}
if (dayDiff === 1) {
return `昨天 ${msgTime.format("HH:mm")}`;
}
// 使用 startOf('year') 抹平年月日,方便按“年”比较
const thisYear = dayjs().startOf("year");
const msgYear = dayjs(sendDate).startOf("year");
const yearDiff = thisYear.diff(msgYear, "year");
if (yearDiff === 0) {
return `${msgTime.format("MM月DD日 HH:mm")}`;
}
return `${msgTime.format("YYYY年MM月DD日 HH:mm")}`;
}
/**
* 通过计算内容容器高度的方式滚动到底部与首页实现保持一致
*
* 为什么不用 `scroll-into-view`
* - 私聊场景经常需要追加/插入消息并保持当前视口稳定
* - `scroll-into-view` 在某些端会触发额外的自动滚动/动画造成跳动体验
*
* 工作方式
* - 使用 `uni.createSelectorQuery().in(vm)` 取到组件内 `selector` 对应节点高度
* - 以节点高度作为 `scrollTop` 的目标值并加上 `extraOffset` 作为底部缓冲
*
* 使用要求
* - `vm` 必须是 Vue 组件实例需要 `vm.$nextTick` `vm.scrollTop`
* - `selector` 对应的节点需要存在且能计算出 `boundingClientRect`
*
* @param {any} vm - Vue 组件实例一般直接传 `this`
* @param {{ selector?: string; extraOffset?: number }} options - 选择器与额外偏移
*/
export function scrollToBottomByContentHeight(
vm,
{ selector = ".chat-content", extraOffset = 200 } = {}
) {
if (!vm) return;
vm.$nextTick(() => {
const query = uni.createSelectorQuery().in(vm);
query
.select(selector)
.boundingClientRect((data) => {
if (data) {
// 这里用高度作为 scrollTop 目标值即可“贴底”,额外加一点缓冲避免极端情况没到最底
vm.scrollTop = Number(data.height || 0) + Number(extraOffset || 0);
}
})
.exec();
});
}
/**
* 处理聊天消息内容若内容为 JSON 字符串且符合预期结构则提取并拼接为可读文本
*
* 说明
* - 首页对话存在部分消息内容为 JSON 字符串的情况如包含 `answerTypeLabel/imageUrl/nearbyPaths`
* - 这里做尽量解析解析失败回退原文的处理避免误伤普通文本消息
*
* @param {string} message - 原始消息文本
* @returns {string} - 处理后的文本
*/
export function processChatMessageContent(message) {
try {
const parsed = JSON.parse(message);
if (typeof parsed === "object" && parsed !== null) {
let content = "";
const { answerTypeLabel, imageUrl, nearbyPaths } = parsed;
if (answerTypeLabel) content += `${answerTypeLabel}\n`;
if (imageUrl) content += `${imageUrl}\n`;
if (nearbyPaths) content += `${nearbyPaths}\n`;
return content ? content.trim() : message;
}
} catch (e) {
return message;
}
return message;
}
/**
* 聊天消息排序 `sendDate` 升序时间相同时用户消息`interactMode=0`排在前面
* 同时会对每条消息的 `message` 字段进行 `processChatMessageContent` 处理
*
* @param {Array<any>} list - 会话详情接口返回的消息列表
* @returns {Array<any>} - 处理并排序后的新数组
*/
export function sortChatMessages(list = []) {
const processedList = (list || []).map((item) => ({
...item,
message: processChatMessageContent(item && item.message),
}));
return processedList.sort((a, b) => {
const timeA = new Date(a && a.sendDate).getTime();
const timeB = new Date(b && b.sendDate).getTime();
if (timeA === timeB) return (a && a.interactMode) - (b && b.interactMode);
return timeA - timeB;
});
}
/**
* Date 格式化为 `HH:mm`
*
* @param {Date} date - 时间对象
* @returns {string} - 格式化字符串
*/
export function formatHHmm(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}