This commit is contained in:
JiXinHui 2025-12-17 16:52:25 +08:00
commit b89af7db93
3 changed files with 207 additions and 106 deletions

View File

@ -14,7 +14,7 @@
scroll-y scroll-y
:scroll-into-view="scrollToView" :scroll-into-view="scrollToView"
:scroll-top="scrollTop" :scroll-top="scrollTop"
scroll-with-animation :scroll-with-animation="!isLoading"
:upper-threshold="20" :upper-threshold="20"
@scroll="handleScroll" @scroll="handleScroll"
@scrolltoupper="handleScrollToUpper" @scrolltoupper="handleScrollToUpper"
@ -47,6 +47,19 @@
</div> </div>
</div> </div>
<!-- 上拉刷新loading -->
<view class="loading-more" v-if="isLoading">
<u-loading mode="circle" color="#4370fe"></u-loading>
</view>
<!-- 到顶部提示 -->
<view
class="no-more-data"
v-if="noMoreData && vuex_msgList && vuex_msgList.length"
>
<text>已经到顶了</text>
</view>
<view <view
v-for="(message, index) in vuex_msgList" v-for="(message, index) in vuex_msgList"
:key="message.id" :key="message.id"
@ -122,7 +135,11 @@
<script> <script>
import HeaderBar from "@/components/HeaderBar.vue"; // import HeaderBar from "@/components/HeaderBar.vue"; //
import dayjs from "dayjs"; // dayjs import {
formatChatShowTime,
scrollToBottomByContentHeight,
shouldShowTime,
} from "@/utils/chat.js";
export default { export default {
name: "ChatDetail", name: "ChatDetail",
@ -171,8 +188,8 @@ export default {
PageIndex: 1, PageIndex: 1,
PageSize: 20, PageSize: 20,
isLoadingHistory: false, isLoading: false,
noMoreHistory: false, noMoreData: false,
}; };
}, },
@ -215,7 +232,7 @@ export default {
// ID // ID
lastMsgId(val) { lastMsgId(val) {
if (!val) return; if (!val) return;
if (this.isLoadingHistory) return; if (this.isLoading) return;
this.$nextTick(() => { this.$nextTick(() => {
this.scrollToBottom(); this.scrollToBottom();
}); });
@ -283,11 +300,20 @@ export default {
// //
getMsgList() { getMsgList() {
return this.$store.dispatch("fetchChatRecord", { return this.$store
dialogueManagementId: this.vuex_msgUser.dialogueManagementId, .dispatch("fetchChatRecord", {
PageIndex: this.PageIndex, dialogueManagementId: this.vuex_msgUser.dialogueManagementId,
PageSize: this.PageSize, PageIndex: this.PageIndex,
}); PageSize: this.PageSize,
})
.then((list) => {
const len = Array.isArray(list) ? list.length : 0;
//
if (len === 0 && this.PageIndex === 1) {
this.noMoreData = true;
}
return list;
});
}, },
handleScroll(e) { handleScroll(e) {
@ -298,65 +324,54 @@ export default {
// //
handleScrollToUpper() { handleScrollToUpper() {
if (this.isLoadingHistory || this.noMoreHistory) return; if (this.isLoading || this.noMoreData) return;
this.isLoadingHistory = true; this.isLoading = true;
const beforeTop = this.currentScrollTop || 0; const beforeTop = this.currentScrollTop || 0;
const beforeHeight = this.currentScrollHeight || 0; const beforeHeight = this.currentScrollHeight || 0;
this.PageIndex += 1; const nextPageIndex = this.PageIndex + 1;
this.scrollToView = ""; this.scrollToView = "";
this.$store this.$store
.dispatch("fetchChatRecordNextPage", { .dispatch("fetchChatRecordNextPage", {
dialogueManagementId: this.vuex_msgUser.dialogueManagementId, dialogueManagementId: this.vuex_msgUser.dialogueManagementId,
PageIndex: this.PageIndex, PageIndex: nextPageIndex,
PageSize: this.PageSize, PageSize: this.PageSize,
}) })
.then((list) => { .then((list) => {
if (!list || !list.length) { if (!list || !list.length) {
this.noMoreHistory = true; this.noMoreData = true;
return; return;
} }
this.$nextTick(() => { this.PageIndex = nextPageIndex;
const query = uni.createSelectorQuery().in(this); // this.$nextTick(() => {
query // const query = uni.createSelectorQuery().in(this);
.select(".chat-content") // query
.boundingClientRect((rect) => { // .select(".chat-content")
const afterHeight = Number(rect && rect.height) || 0; // .boundingClientRect((rect) => {
const delta = afterHeight - beforeHeight; // const afterHeight = Number(rect && rect.height) || 0;
if (delta > 0) { // const delta = afterHeight - beforeHeight;
this.scrollTop = beforeTop + delta; // if (delta > 0) {
} // this.scrollTop = beforeTop + delta;
}) // }
.exec(); // })
}); // .exec();
}) // });
.catch(() => {
this.PageIndex = Math.max(1, this.PageIndex - 1);
}) })
.finally(() => { .finally(() => {
setTimeout(() => { setTimeout(() => {
this.isLoadingHistory = false; this.isLoading = false;
}, 50); }, 100);
}); });
}, },
// //
scrollToBottom() { scrollToBottom() {
// if (this.vuex_msglist.length > 0) { if (this.isLoading) return;
// const lastMsg = this.vuex_msglist[this.vuex_msglist.length - 1]; scrollToBottomByContentHeight(this, {
// this.scrollToView = "msg-" + lastMsg.id; selector: ".chat-content",
// } extraOffset: 200,
});
//
if (this.scrollToView === "bottom-anchor") {
this.scrollToView = "";
this.$nextTick(() => {
this.scrollToView = "bottom-anchor";
});
return;
}
this.scrollToView = "bottom-anchor";
}, },
// //
@ -368,56 +383,11 @@ export default {
// //
isShowTime(index) { isShowTime(index) {
if (index == 0) { return shouldShowTime(this.vuex_msgList, index);
return true;
}
let isTime = new Date(this.vuex_msgList[index].sendDate).getTime(); //
const time = new Date(this.vuex_msgList[index - 1].sendDate).getTime(); //
// 30
return isTime - time > 30 * 60 * 1000;
}, },
// //
formatShowTime(sendDate) { formatShowTime(sendDate) {
// 1 60 x 60 hh:mm return formatChatShowTime(sendDate);
// hh:mm
// , MMDD hh:mm
// YYYYMMDD
var isTime = dayjs(); //
var msgTime = dayjs(sendDate); //
if (isTime.diff(msgTime, "second") < 60) {
return "刚刚";
}
if (isTime.diff(msgTime, "hour") < 1) {
return `${isTime.diff(msgTime, "minute")}分钟前`;
}
// 使 startOf('day') 00:00:00
var today = dayjs().startOf("day");
var msgDay = dayjs(sendDate).startOf("day");
var dayDiff = today.diff(msgDay, "day");
if (dayDiff === 0) {
return `今天 ${msgTime.format("HH:mm")}`;
}
if (dayDiff === 1) {
return `昨天 ${msgTime.format("HH:mm")}`;
}
// 使 startOf('year')
var thisYear = dayjs().startOf("year");
var msgYear = dayjs(sendDate).startOf("year");
var yearDiff = thisYear.diff(msgYear, "year");
if (yearDiff === 0) {
//
return `${msgTime.format("MM月DD日 HH:mm")}`; // HH:mm:ss HH:mm
}
//
return `${msgTime.format("YYYY年MM月DD日 HH:mm")}`;
}, },
}, },
}; };
@ -448,18 +418,18 @@ export default {
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
// .loading-more { .loading-more {
// text-align: center; text-align: center;
// margin-bottom: 32rpx; margin-bottom: 32rpx;
// } }
// .no-more-data { .no-more-data {
// text-align: center; text-align: center;
// font-size: 24rpx; font-size: 24rpx;
// color: #999; color: #999;
// margin-bottom: 32rpx; margin-bottom: 32rpx;
// padding: 10rpx 0; padding: 10rpx 0;
// } }
.teacher-info-card { .teacher-info-card {
background-color: #ffffff; background-color: #ffffff;

View File

@ -909,6 +909,8 @@ export default {
// //
handleGetConversationDetail() { handleGetConversationDetail() {
if (!this.currentDMid) return;
this.$u.api this.$u.api
.GetConversationDetail({ .GetConversationDetail({
"Item1.Id": this.currentDMid, "Item1.Id": this.currentDMid,
@ -932,6 +934,8 @@ export default {
// >1退 // >1退
refreshPageWithFallback() { refreshPageWithFallback() {
if (!this.currentDMid) return;
const currentIndex = this.pageQuery.PageIndex || 1; const currentIndex = this.pageQuery.PageIndex || 1;
const pageSize = this.pageQuery.PageSize; const pageSize = this.pageQuery.PageSize;
@ -983,6 +987,8 @@ export default {
onScrollToUpper() { onScrollToUpper() {
console.log("触发上拉刷新"); console.log("触发上拉刷新");
if (!this.currentDMid) return;
// //
if (this.noMoreData || this.isSwitchingConversation) { if (this.noMoreData || this.isSwitchingConversation) {
return; return;
@ -1035,7 +1041,7 @@ export default {
.finally(() => { .finally(() => {
setTimeout(() => { setTimeout(() => {
this.isLoading = false; this.isLoading = false;
}, 300); }, 100);
}); });
}, },

125
utils/chat.js Normal file
View File

@ -0,0 +1,125 @@
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();
});
}