diff --git a/pages/chat/index.vue b/pages/chat/index.vue index 68235d6..1a4ce7c 100644 --- a/pages/chat/index.vue +++ b/pages/chat/index.vue @@ -14,7 +14,7 @@ scroll-y :scroll-into-view="scrollToView" :scroll-top="scrollTop" - scroll-with-animation + :scroll-with-animation="!isLoading" :upper-threshold="20" @scroll="handleScroll" @scrolltoupper="handleScrollToUpper" @@ -47,6 +47,19 @@ + + + + + + + + 已经到顶了 + + import HeaderBar from "@/components/HeaderBar.vue"; // 导入头部组件 -import dayjs from "dayjs"; // 导入 dayjs +import { + formatChatShowTime, + scrollToBottomByContentHeight, + shouldShowTime, +} from "@/utils/chat.js"; export default { name: "ChatDetail", @@ -171,8 +188,8 @@ export default { PageIndex: 1, PageSize: 20, - isLoadingHistory: false, - noMoreHistory: false, + isLoading: false, + noMoreData: false, }; }, @@ -215,7 +232,7 @@ export default { // 监听最后一条消息的ID变化,滚动到底部 lastMsgId(val) { if (!val) return; - if (this.isLoadingHistory) return; + if (this.isLoading) return; this.$nextTick(() => { this.scrollToBottom(); }); @@ -283,11 +300,20 @@ export default { // 加载对话消息 getMsgList() { - return this.$store.dispatch("fetchChatRecord", { - dialogueManagementId: this.vuex_msgUser.dialogueManagementId, - PageIndex: this.PageIndex, - PageSize: this.PageSize, - }); + return this.$store + .dispatch("fetchChatRecord", { + dialogueManagementId: this.vuex_msgUser.dialogueManagementId, + 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) { @@ -298,65 +324,54 @@ export default { // 滚动到顶部,加载下一页历史消息 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 beforeHeight = this.currentScrollHeight || 0; - this.PageIndex += 1; + const nextPageIndex = this.PageIndex + 1; this.scrollToView = ""; this.$store .dispatch("fetchChatRecordNextPage", { dialogueManagementId: this.vuex_msgUser.dialogueManagementId, - PageIndex: this.PageIndex, + PageIndex: nextPageIndex, PageSize: this.PageSize, }) .then((list) => { if (!list || !list.length) { - this.noMoreHistory = true; + this.noMoreData = true; return; } - this.$nextTick(() => { - const query = uni.createSelectorQuery().in(this); - query - .select(".chat-content") - .boundingClientRect((rect) => { - const afterHeight = Number(rect && rect.height) || 0; - const delta = afterHeight - beforeHeight; - if (delta > 0) { - this.scrollTop = beforeTop + delta; - } - }) - .exec(); - }); - }) - .catch(() => { - this.PageIndex = Math.max(1, this.PageIndex - 1); + this.PageIndex = nextPageIndex; + // this.$nextTick(() => { + // const query = uni.createSelectorQuery().in(this); + // query + // .select(".chat-content") + // .boundingClientRect((rect) => { + // const afterHeight = Number(rect && rect.height) || 0; + // const delta = afterHeight - beforeHeight; + // if (delta > 0) { + // this.scrollTop = beforeTop + delta; + // } + // }) + // .exec(); + // }); }) .finally(() => { setTimeout(() => { - this.isLoadingHistory = false; - }, 50); + this.isLoading = false; + }, 100); }); }, // 滚动到底部 scrollToBottom() { - // if (this.vuex_msglist.length > 0) { - // const lastMsg = this.vuex_msglist[this.vuex_msglist.length - 1]; - // this.scrollToView = "msg-" + lastMsg.id; - // } - - // 滚动到底部锚点 - if (this.scrollToView === "bottom-anchor") { - this.scrollToView = ""; - this.$nextTick(() => { - this.scrollToView = "bottom-anchor"; - }); - return; - } - this.scrollToView = "bottom-anchor"; + if (this.isLoading) return; + scrollToBottomByContentHeight(this, { + selector: ".chat-content", + extraOffset: 200, + }); }, // 格式化时间 @@ -368,56 +383,11 @@ export default { // 是否显示时间 isShowTime(index) { - if (index == 0) { - 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; + return shouldShowTime(this.vuex_msgList, index); }, // 格式化显示时间 formatShowTime(sendDate) { - // 《消息时间为今天发送》 1分钟内显示 ‘刚刚’ 超过一分钟 且小于60分钟 显示 ‘x分钟前’ 大于等于60分钟显示 ‘今天 hh:mm’ - // 《消息时间为昨天发送》显示 ‘昨天 hh:mm’ - // 《消息时间为昨天以前,并且是今年发起》 显示 ‘MM月DD日 hh:mm’ - // 《消息时间为往年发送》 显示 ‘YYYY年MM月DD日’ - 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")}`; + return formatChatShowTime(sendDate); }, }, }; @@ -448,18 +418,18 @@ export default { height: 100%; overflow-y: scroll; - // .loading-more { - // text-align: center; - // margin-bottom: 32rpx; - // } + .loading-more { + text-align: center; + margin-bottom: 32rpx; + } - // .no-more-data { - // text-align: center; - // font-size: 24rpx; - // color: #999; - // margin-bottom: 32rpx; - // padding: 10rpx 0; - // } + .no-more-data { + text-align: center; + font-size: 24rpx; + color: #999; + margin-bottom: 32rpx; + padding: 10rpx 0; + } .teacher-info-card { background-color: #ffffff; diff --git a/pages/home/index/index.vue b/pages/home/index/index.vue index 783d8c6..0556eab 100644 --- a/pages/home/index/index.vue +++ b/pages/home/index/index.vue @@ -909,6 +909,8 @@ export default { // 刷新当前对话的消息详情 handleGetConversationDetail() { + if (!this.currentDMid) return; + this.$u.api .GetConversationDetail({ "Item1.Id": this.currentDMid, @@ -932,6 +934,8 @@ export default { // 刷新当前页数据;若当前页为空且页码>1,则自动回退上一页 refreshPageWithFallback() { + if (!this.currentDMid) return; + const currentIndex = this.pageQuery.PageIndex || 1; const pageSize = this.pageQuery.PageSize; @@ -983,6 +987,8 @@ export default { onScrollToUpper() { console.log("触发上拉刷新"); + if (!this.currentDMid) return; + // 如果已经没有更多数据或正在切换对话或当前对话为空(新建对话),不再触发上拉刷新 if (this.noMoreData || this.isSwitchingConversation) { return; @@ -1035,7 +1041,7 @@ export default { .finally(() => { setTimeout(() => { this.isLoading = false; - }, 300); + }, 100); }); }, diff --git a/utils/chat.js b/utils/chat.js new file mode 100644 index 0000000..714a464 --- /dev/null +++ b/utils/chat.js @@ -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(); + }); +}