| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086 |
- <template>
- <view class="chating_container">
- <!-- <view style="margin:100px;">1111</view> -->
- <chating-header ref="chatingHeaderRef" style="z-index: 100;" :storeCurrentConversation="currentConversation" :mutipleCheckVisible="mutipleCheckVisible" @mutipleCheckUpdate="mutipleCheckUpdate" @back="onHeaderBack" @enterSubPage="onEnterSubPage"></chating-header>
-
- <view class="chat_content_wrap">
- <list v-if="renderContent" class="chat-list" :class="[isInvertedMode ? 'inverted' : '']" ref="list" :show-scrollbar="false" @click="onListClick" :style="{opacity: isLayoutReady ? 1 : 0}">
-
- <!-- Normal Mode: Top Loader (History) -->
- <cell ref="normal-top-loader" @appear="onTopLoaderAppear" v-if="!isInvertedMode && storeHasMoreMessage && storeHistoryMessageList.length >= 10 && !initLoading" class="normal-loader">
- <view class="top_loader_container">
- <loading-indicator class="top_loader_indicator" :animating="true"></loading-indicator>
- </view>
- </cell>
- <!-- Inverted Mode: Bottom Anchor (Visual Bottom / Physical Top) -->
- <cell v-if="isInvertedMode" ref="invertedBottomAnchor">
- <view style="height: 1px; width: 750rpx;"></view>
- </cell>
- <cell v-for="(item, index) in currentRenderList" :key="item.clientMsgID" :ref="'msg-' + (item.clientMsgID || index)">
- <view :class="[isInvertedMode ? 'inverted-content' : '']">
- <!-- 预加载触发器:当渲染到列表第 5 条消息时触发(实际上是倒数第5条,因为是历史消息) -->
- <!-- 注意:Normal 模式下 index 0 是最旧的消息,index 5 是较旧的消息 -->
- <!-- 我们希望用户向上滚动接近顶部时触发,即接近 index 0 -->
- <!-- 所以当 index === 5 的元素出现时,说明用户已经滚到了很上面 -->
- <view v-if="index === 5 && !isInvertedMode" @appear="onPreloadAppear" style="height: 1px; width: 1px; opacity: 0; position: absolute;"></view>
- <view v-if="isInvertedMode && index === currentRenderList.length - 5" @appear="onPreloadAppear" style="height: 1px; width: 1px; opacity: 0; position: absolute;"></view>
-
- <view v-if="shouldShowTime(item, index) && !isInvertedMode" class="time_gap_line">
- <text class="time_gap_text">{{ getTimeGap(item, index) }}</text>
- </view>
- <message-item-render
- :source="item"
- :senderFaceUrl="item.senderFaceUrl"
- :senderNickname="item.senderNickname"
- :isSender="item.sendID === storeCurrentUserID"
- :mutipleCheckVisible="mutipleCheckVisible"
- @longPress="handleLongPress"
- @enterSubPage="onEnterSubPage">
- </message-item-render>
- <view v-if="shouldShowTime(item, index) && isInvertedMode" class="time_gap_line">
- <text class="time_gap_text">{{ getTimeGap(item, index) }}</text>
- </view>
- </view>
- </cell>
-
- <!-- Normal Mode: Bottom Anchor -->
- <cell v-if="!isInvertedMode" ref="normalBottomAnchor">
- <view style="height: 1px; width: 750rpx;"></view>
- </cell>
-
- <!-- Inverted Mode: Top Loader (Visual Top / Physical Bottom) -->
- <cell ref="inverted-top-loader" @appear="onTopLoaderAppear" v-if="isInvertedMode && storeHasMoreMessage && !initLoading">
- <view class="top_loader_container" :class="[isInvertedMode ? 'inverted-content' : '']">
- <loading-indicator class="top_loader_indicator" :animating="true"></loading-indicator>
- </view>
- </cell>
- </list>
- <view v-if="initLoading" class="page_loading_container" @touchmove.stop.prevent>
- <!-- <loading-indicator class="page_loading_indicator" :animating="true"></loading-indicator> -->
- <!-- <text class="page_loading_text">加载中...</text> -->
- </view>
- </view>
-
-
- <chating-footer ref="chatingFooterRef" :footerOutsideFlag="footerOutsideFlag" @scrollToBottom="scrollToBottom" @enterSubPage="onEnterSubPage" v-if="!mutipleCheckVisible"></chating-footer>
- <view v-if="mutipleCheckVisible" class="mutiple_action_container">
- <view @click="forward('MergeForWard')" class="action_item">
- <image class="action_icon" src="/pages_im/static/images/mutiple_merge.png" />
- <text class="action_text">合并转发</text>
- </view>
- <view @click="mutipleRemove" class="action_item">
- <image class="action_icon" src="/pages_im/static/images/mutiple_delete.png" />
- <text class="action_text action_text_red">删除</text>
- </view>
- </view>
- <message-menu
- v-if="menuData.visible"
- :message="menuData.message"
- :isSender="menuData.isSender"
- :paterWidth="menuData.paterWidth"
- :rect="menuData.rect"
- @close="closeMenuGlobal"
- @enterSubPage="onEnterSubPage"/>
-
-
- </view>
- </template>
- <script>
- import '../../../util/polyfill-nvue';
- import { mapGetters, mapActions } from 'vuex';
- import IMSDK from 'openim-uniapp-polyfill';
- import { MessageStatus, SessionType, MessageType } from '@/pages_im/constant/imConstants';
- import { ContactChooseTypes, GroupMemberListTypes, PageEvents, SendMessageFailedType } from '../../../constant';
- import ChatingHeader from './components/ChatingHeader.vue';
- import ChatingFooter from './components/ChatingFooter/index.vue';
- import MessageItemRender from './components/MessageItem/index.vue';
- import MessageMenu from './components/MessageItem/MessageMenu.vue';
- import dayjs from 'dayjs';
- import { markConversationAsRead, parseMessageByType, callingModule, getIMSDK } from '../../../util/imCommon';
- const dom = uni.requireNativePlugin('dom');
- // 封装获取节点矩形信息的工具函数
- export default {
- components: {
- ChatingHeader,
- ChatingFooter,
- MessageItemRender,
- MessageMenu
- },
- data() {
- return {
- menuData: {
- visible: false,
- message: null,
- rect: {},
- isSender: false,
- paterWidth: 0
- },
- renderContent: true,
- footerOutsideFlag: 0,
- menuOutsideFlag: 0,
- initLoading: true,
- mutipleCheckVisible: false,
- back2Tab: false,
- timer: null,
- refreshing: false,
- messageLoadState: {
- loading: false
- },
- canShowBot: false,
- conversationID: '',
- localConversation: null,
- isBackAction: false,
- isEnterSubPage: false,
- loadingStartTime: 0,
- layoutRetryCount: 0,
- stabilityCounter: 0,
- layoutTimer: null,
- isLayoutReady: false,
- isInvertedMode: false, // 默认使用 Normal Mode,避免少消息时初始倒置导致靠底
- checkModeTimer: null,
- isHistoryLoading: false, // 标记是否正在加载历史消息
- historyAnchorID: '' // 加载历史消息时的定位锚点ID
- };
- },
- computed: {
- ...mapGetters([
- 'timStore',
- 'storeHistoryMessageList',
- 'storeHasMoreMessage',
- 'storeCurrentUserID',
- 'storeFriendList',
- 'storeCurrentGroup'
- ]),
- currentConversation() {
- return this.localConversation || {};
- },
- imType() {
- return this.timStore?.imType || '';
- },
- // 反转消息列表:[最新消息, ..., 最旧消息]
- reversedMessageList() {
- // 确保按时间倒序排列:时间越大(越新)越靠前
- // 配合 Inverted Mode,index 0 (最新) 在视觉底部,index N (最旧) 在视觉顶部
- return [...this.storeHistoryMessageList].sort((a, b) => (a.sendTime || 0) - (b.sendTime || 0)).reverse();
- },
- currentRenderList() {
- if (this.isInvertedMode) {
- return this.reversedMessageList;
- }
- // Normal Mode: 按时间正序排列 (Old -> New)
- return [...this.storeHistoryMessageList].sort((a, b) => (a.sendTime || 0) - (b.sendTime || 0));
- }
- },
- watch: {
- storeHistoryMessageList: {
- handler(newVal, oldVal) {
- // 如果正在加载历史消息,不触发自动滚动
- if (this.isHistoryLoading) return;
- // 只有当有新消息新增(即列表末尾元素变化)时,才滚动到底部
- // 避免加载历史消息(列表头部变化)时触发滚动
- const newLast = newVal && newVal.length > 0 ? newVal[newVal.length - 1] : null;
- const oldLast = oldVal && oldVal.length > 0 ? oldVal[oldVal.length - 1] : null;
-
- if (!oldLast || (newLast && newLast.clientMsgID !== oldLast.clientMsgID)) {
- // 新消息来了,触发滚动
- this.triggerModeCheck();
- }
- },
- }
- },
- onShow() {
- try {
- const app = getApp();
- if (app && app.globalData) {
- if (app.globalData.IM_back2Tab !== undefined) {
- this.back2Tab = app.globalData.IM_back2Tab;
- }
- if (app.globalData.IM_CURRENT_CONVERSATION) {
- const newConv = app.globalData.IM_CURRENT_CONVERSATION;
- // 检查是否需要重新初始化会话
- // 只要页面可见,且会话ID一致,就必须确保页面可见性状态正确
- if (this.conversationID === newConv.conversationID) {
- // 同一会话,无需重新加载,直接确保显示即可
- // 如果之前被隐藏了(虽然现在我们不再隐藏),这里做个双保险
- this.initLoading = false;
- this.isLayoutReady = true;
- this.renderContent = true;
-
- // 恢复监听器
- this.setPageListener();
- // 刷新一下未读状态等
- this.checkCalling();
- let userID = this.currentConversation?.userID;
- this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
-
- } else if (!this.conversationID || this.conversationID !== newConv.conversationID) {
- // 不同会话,执行完整初始化
- this.handleConversationInit(newConv);
- }
- }
- }
- } catch (e) {
- console.error('onShow error', e);
- }
- },
- onLoad(options) {
- const app = getApp();
- let conversation = null;
- // 1. 尝试从 GlobalData 获取
- if (app && app.globalData && app.globalData.IM_CURRENT_CONVERSATION) {
- conversation = app.globalData.IM_CURRENT_CONVERSATION;
- if (app.globalData.IM_back2Tab !== undefined) {
- this.back2Tab = app.globalData.IM_back2Tab;
- }
- }
- // 2. 尝试从 Storage 获取 (兜底)
- if (!conversation) {
- try {
- const storageData = uni.getStorageSync('IM_CURRENT_CONVERSATION');
- if (storageData) {
- conversation = storageData;
- }
- } catch (e) {}
- }
- if (conversation) {
- this.localConversation = conversation;
- this.conversationID = conversation.conversationID;
- this.renderContent = true;
- this.initLoading = true; // 保持 Loading 直到消息加载并计算完布局
-
- // 同步到 Store
- this.$store.commit('conversation/SET_CURRENT_CONVERSATION', conversation);
-
- // 如果是工作群,获取群信息
- if (conversation.conversationType === SessionType.WorkingGroup) {
- this.$store.dispatch("conversation/getCurrentGroup", conversation.groupID);
- this.$store.dispatch("conversation/getCurrentMemberInGroup", conversation.groupID);
- }
- }
-
- this.initLogic();
- this.checkCalling();
-
- let userID = this.currentConversation?.userID;
- this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
-
- // 监听预加载事件
- uni.$on('IM_PRELOAD_CONVERSATION', this.preloadConversation);
- },
- onHide() {
- // 当页面隐藏时(如返回上一页),重置部分状态
- // 但不要重置 conversationID,因为下次进来可能还是同一个会话
- // 关键:必须允许重新加载
- this.messageLoadState.loading = false;
- if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
- // 优化:不要在 onHide 时隐藏内容,避免返回时白屏
- // this.isLayoutReady = false;
- this.disposePageListener();
- markConversationAsRead({...this.currentConversation}, true);
- if (this.timer) clearInterval(this.timer);
- },
- onUnload() {
- //uni.$off('IM_PRELOAD_CONVERSATION', this.preloadConversation);
- this.disposePageListener();
- markConversationAsRead({...this.currentConversation}, true);
- this.resetConversationState();
- this.resetMessageState();
- if (this.timer) clearInterval(this.timer);
-
- this.localConversation = null;
- this.conversationID = '';
- this.initLoading = true;
- this.renderContent = false;
- this.isInvertedMode = false; // 退出时重置为 Normal Mode
- },
-
- onBackPress() {
- if (this.mutipleCheckVisible) {
- this.mutipleCheckUpdateHandler({ flag: false });
- return true;
- }
- this.isBackAction = true;
- return false;
- },
- methods: {
- ...mapActions('message', ['resetMessageState', 'deleteMessages']),
- ...mapActions('conversation', ['resetConversationState']),
-
-
- /**
- * 处理会话初始化逻辑(复用于 onShow)
- */
- async handleConversationInit(newConv) {
- this.disposePageListener();
-
- // 内存优化:手动释放本地大对象引用
- this.localConversation = null;
- this.menuData.message = null;
-
- this.resetConversationState();
- // 性能优化:并行执行重置和加载,不强制等待 reset 完成,只要保证新数据覆盖即可
- // resetMessageState 主要是为了清空 store,防止旧数据闪现
- this.resetMessageState();
-
- this.localConversation = newConv;
- this.conversationID = newConv.conversationID;
- this.renderContent = true;
- this.initLoading = true; // 重新加载时显示 Loading
- this.isLayoutReady = false;
- this.loadingStartTime = Date.now();
- this.layoutRetryCount = 0;
- if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
-
- // 关键:重置本地加载状态
- this.messageLoadState.loading = false;
- this.isHistoryLoading = false;
- this.historyAnchorID = '';
- this.isInvertedMode = false; // 切换会话时重置为 Normal Mode
-
- this.$nextTick(() => {
- this.$store.commit('conversation/SET_CURRENT_CONVERSATION', this.localConversation);
- if (this.localConversation.conversationType === SessionType.WorkingGroup) {
- this.$store.dispatch("conversation/getCurrentGroup", this.localConversation.groupID);
- this.$store.dispatch("conversation/getCurrentMemberInGroup", this.localConversation.groupID);
- }
- // 立即启动加载,不再等待
- this.initLogic(false);
- let userID = this.currentConversation?.userID;
- this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
- });
- },
-
-
- // --- 事件处理 onLoad emit---
- preloadConversation(newConv) {
- if (!newConv) return;
-
- // 清理旧的监听器,防止重复注册
- this.disposePageListener();
-
- this.localConversation = newConv;
- this.conversationID = newConv.conversationID;
- this.$store.commit('conversation/SET_CURRENT_CONVERSATION', newConv);
-
- // 确保先清空消息状态,再进行后续操作
- this.resetMessageState().then(() => {
- // 重置渲染状态,强制列表重新渲染,避免视图残留或 DOM 异常
- this.renderContent = false;
-
- // 关键:重置本地加载状态,防止因上一次加载未完成而导致死锁
- this.messageLoadState.loading = false;
- this.isHistoryLoading = false;
- this.historyAnchorID = '';
- this.isInvertedMode = false; // 预加载时重置为 Normal Mode
-
- this.initLoading = true;
- this.isLayoutReady = false;
- this.loadingStartTime = Date.now();
- this.layoutRetryCount = 0;
- if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
-
- if (this.localConversation.conversationType === SessionType.WorkingGroup) {
- this.$store.dispatch("conversation/getCurrentGroup", this.localConversation.groupID);
- this.$store.dispatch("conversation/getCurrentMemberInGroup", this.localConversation.groupID);
- }
-
- this.$nextTick(() => {
- this.renderContent = true;
- this.initLogic(false);
- });
- });
- },
-
-
- /**
- * 页面初始化主逻辑
- */
- async initLogic(shouldReset = true) {
- if (shouldReset) {
- await this.resetMessageState();
- this.initLoading = true;
- this.isLayoutReady = false;
- this.loadingStartTime = Date.now();
- this.layoutRetryCount = 0;
- this.isInvertedMode = false; // 初始化时重置为 Normal Mode
- if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
- }
-
- this.setPageListener();
- this.loadMessageList(false);
-
- // 兜底:5秒后强制结束 loading
- setTimeout(() => {
- if (this.initLoading) {
- this.initLoading = false;
- }
- }, 5000);
- },
- /**
- * 加载消息列表
- * @param {boolean} isHistory 是否加载历史消息
- */
- loadMessageList(isHistory = false) {
- if (this.messageLoadState.loading) return;
- // 预加载命中优化
- if (!isHistory && this.storeHistoryMessageList.length > 0) {
- // 先滚动到底部,且不显示页面(保持透明)
- this.$nextTick(() => {
- this.scrollToBottom();
-
- // 延迟一点点再显示页面,确保滚动已经完成,避免视觉跳动
- setTimeout(() => {
- this.isLayoutReady = true;
- this.initLoading = false;
- }, 50);
- });
- return;
- }
- this.messageLoadState.loading = true;
- // 标记正在加载历史消息,防止 watch 触发滚动到底部
- if (isHistory) {
- this.isHistoryLoading = true;
- }
-
- const conversationID = this.conversationID || this.currentConversation?.conversationID;
-
- if (!conversationID) {
- this.messageLoadState.loading = false;
- this.initLoading = false;
- this.isHistoryLoading = false;
- return;
- }
-
- const startClientMsgID = isHistory && this.storeHistoryMessageList.length > 0
- ? this.storeHistoryMessageList[0].clientMsgID : '';
- this.$store.dispatch('message/getHistoryMesageList', {
- conversationID,
- startClientMsgID,
- count: 20
- }).then(res => {
- this.messageLoadState.loading = false;
- if (!isHistory) {
- // 强制使用 Normal 模式 (从上往下),解决小屏倒置问题
- // 除非消息数量极大(>1000),否则不使用 Inverted 模式
- // 当消息数量不足 10 条时,使用 Normal 模式
- // 需求:当超过10条时,使用倒置模式,消息从底部往上排列(时间越大越靠底)
- if (this.storeHistoryMessageList.length < 10) {
- this.isInvertedMode = false;
-
- // Normal Mode (Top Aligned)
- // 不进行滚动,直接显示
- this.initLoading = false;
- this.isLayoutReady = true;
- } else {
- this.isInvertedMode = true;
-
- // Inverted Mode (Bottom Aligned)
- // 优化:Inverted List 的物理顶部就是视觉底部,且数据是按时间倒序排列(最新在Index 0)
- // 所以渲染完成后,默认位置就是最新消息处,无需手动滚动,直接显示,消除闪烁和白屏等待。
- this.$nextTick(() => {
- this.initLoading = false;
- this.isLayoutReady = true;
- });
- }
- } else {
- if (this.isInvertedMode) {
- // 加载历史消息:无感保持位置
- // Inverted Mode 下,新消息添加在列表末尾(物理底部),
- // 原有的列表内容位置不变,用户只需继续滚动即可看到新内容
- // 不需要手动 scrollToElement,否则会导致抖动
- this.$nextTick(() => {
- // 延迟一点点确保渲染完成后再允许 watch 触发
- setTimeout(() => {
- this.isHistoryLoading = false;
- this.historyAnchorID = ''; // 清除锚点
- }, 50);
- });
- } else {
- // Normal Mode 下加载历史消息,需要手动滚动到锚点
- const anchorID = this.historyAnchorID || startClientMsgID;
- if (anchorID) {
- this.$nextTick(() => {
- const targetRef = 'msg-' + anchorID;
- const el = this.$refs[targetRef];
-
- if (el && Array.isArray(el) && el.length > 0) {
- dom.scrollToElement(el[0], { animated: false, offset: 0 });
- }
-
- setTimeout(() => {
- this.isHistoryLoading = false;
- this.historyAnchorID = ''; // 清除锚点
- }, 50);
- });
- } else {
- this.isHistoryLoading = false;
- }
- }
- }
- // 只有非历史消息(即初始加载或发送新消息)才触发模式检查和自动滚动
- // 加载历史消息时,我们已经手动处理了位置保持,绝对不能再触发自动滚动
- if (!isHistory) {
- if (this.storeHistoryMessageList.length <= 20) { // 简单判断是否初始
- this.triggerModeCheck();
- }
- }
- }).catch(err => {
- this.messageLoadState.loading = false;
- this.initLoading = false;
- this.isHistoryLoading = false;
- console.error("加载消息失败", err);
- });
- },
-
-
-
- /**
- * 触发模式检查
- */
- triggerModeCheck() {
- if (this.checkModeTimer) clearTimeout(this.checkModeTimer);
- this.checkModeTimer = setTimeout(() => {
- this.checkLayoutMode();
- }, 50);
- },
- /**
- * 检查并切换布局模式
- * Normal Mode: 如果内容超过一屏,切换到 Inverted Mode
- * Inverted Mode: 保持不变 (除非清空消息,这里暂不处理)
- */
- async checkLayoutMode() {
- // 仅用于触发滚动和结束Loading
- // 如果是初始加载,不要在这里立即结束 Loading,交给 loadMessageList 的回调处理
- // 避免滚动还没完成就显示了
- if (!this.initLoading && !this.isInvertedMode) {
- this.scrollToBottom();
- }
- },
- tryStopLoading(delay = 100) {
- if (this.initLoading) {
- // 计算剩余需要的等待时间,确保复杂消息至少有一定渲染时间
- const now = Date.now();
- const lastItem = this.reversedMessageList[0]; // 注意:reversedList[0] 是最新消息
- // 即使是普通文本消息,也给予至少 500ms 的 Loading 时间
- // 复杂消息给予 1000ms
- const minDuration = this.isComplexMessage(lastItem) ? 200 : 0;
- const elapsed = now - (this.loadingStartTime || 0);
- const remaining = Math.max(0, minDuration - elapsed);
-
- const finalDelay = Math.max(delay, remaining);
-
- // 清除之前的定时器,防止过早结束
- if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
-
- // 延迟结束 Loading,确保布局调整后的渲染已上屏
- this.stopLoadingTimer = setTimeout(() => {
- this.initLoading = false;
- this.stopLoadingTimer = null;
- // 只有当 Loading 真正结束时,才允许显示列表
- this.$nextTick(() => {
- this.isLayoutReady = true;
- });
- }, finalDelay);
- }
- },
- scrollToBottom() {
- this.$nextTick(() => {
- if (this.isInvertedMode) {
- // Inverted Mode: 滚动到 Visual Bottom (Physical Top)
- // 我们的模板中有一个专门的 anchor: invertedBottomAnchor
- const bottomAnchor = this.$refs['invertedBottomAnchor'];
- if (bottomAnchor) {
- dom.scrollToElement(bottomAnchor, { animated: false, offset: 0 });
- } else {
- // 兜底:滚动到第一条数据 (最新消息)
- if (this.currentRenderList.length > 0) {
- const firstItem = this.currentRenderList[0];
- const refName = 'msg-' + (firstItem.clientMsgID || 0);
- const el = this.$refs[refName];
- if (el && Array.isArray(el) && el.length > 0) {
- dom.scrollToElement(el[0], { animated: false, offset: 0 });
- }
- }
- }
- return;
- }
-
- // Normal Mode: 滚动到最后一条消息
- // 优先滚动到最后一条消息,比滚动到锚点更精准
- const listLen = this.currentRenderList.length;
-
- if (listLen > 0) {
- const lastItem = this.currentRenderList[listLen - 1];
- // 确保 ref key 与模板中一致:'msg-' + (item.clientMsgID || index)
- const refName = 'msg-' + (lastItem.clientMsgID || (listLen - 1));
- const el = this.$refs[refName];
-
- if (el && Array.isArray(el) && el.length > 0) {
- dom.scrollToElement(el[0], { animated: false, offset: 0 });
- return;
- }
- }
-
- // 兜底:滚动到锚点
- const anchorName = 'normalBottomAnchor';
- const bottomAnchor = this.$refs[anchorName];
- if (bottomAnchor) {
- dom.scrollToElement(bottomAnchor, { animated: false, offset: 0 });
- }
- });
- },
-
- isComplexMessage(item) {
- if (!item) return false;
- // 图片、视频、文件等视为复杂消息
- const type = item.type;
- return type === 'IMG' || type === 'VIDEO' || type === 'FILE' || type === 'CUSTOM';
- },
- onTopLoaderAppear() {
- if (this.initLoading || this.messageLoadState.loading || !this.storeHasMoreMessage) return;
- // 增加防抖
- if (this.preloadTimer) clearTimeout(this.preloadTimer);
- this.preloadTimer = setTimeout(() => {
- // 记录当前顶部消息ID作为锚点
- if (this.currentRenderList.length > 0) {
- this.historyAnchorID = this.currentRenderList[0].clientMsgID;
- }
- this.loadMessageList(true);
- }, 100);
- },
-
- onPreloadAppear() {
- // 预加载触发
- if (this.messageLoadState.loading || !this.storeHasMoreMessage) return;
-
- // 增加防抖
- if (this.preloadTimer) clearTimeout(this.preloadTimer);
- this.preloadTimer = setTimeout(() => {
- // 记录触发点(Index 5)作为锚点,避免大幅跳动
- if (this.currentRenderList.length > 5) {
- this.historyAnchorID = this.currentRenderList[5].clientMsgID;
- } else if (this.currentRenderList.length > 0) {
- this.historyAnchorID = this.currentRenderList[0].clientMsgID;
- }
- this.loadMessageList(true);
- }, 100);
- },
- // --- 辅助函数 ---
- isUserInFriendList(userID, friendList) {
- if (!friendList) return false;
- return friendList.some(f => f.userID === userID);
- },
-
- /**
- * 判断是否显示时间
- * @param {Object} item 当前消息
- * @param {Number} index 当前消息在 reversedMessageList 中的索引
- */
- shouldShowTime(item, index) {
- if (this.isInvertedMode) {
- // Inverted Mode (New -> Old)
- // 视觉最顶部是列表最后一条 (index = length - 1)
- // 但是因为是倒置列表,我们遍历是从 index 0 开始(视觉最底部)
- // 我们的目标是:如果当前消息 (index) 和上一条消息 (index+1, 更旧) 时间间隔大,则在当前消息上方显示时间
-
- // 1. 最旧的消息(视觉最顶部,列表最后一条)总是显示时间(如果它是第一条被加载的)
- if (index === this.currentRenderList.length - 1) return true;
-
- // 2. 比较当前消息 (index) 和更旧的消息 (index + 1)
- const prevItem = this.currentRenderList[index + 1];
- if (!prevItem) return true;
-
- // 注意:这里比较的是 当前消息时间 - 上一条(更旧)消息时间
- return (item.sendTime - prevItem.sendTime) > 5 * 60 * 1000;
- } else {
- // Normal Mode (Old -> New)
- // 视觉最顶部是列表第一条 (index = 0)
- if (index === 0) return true;
-
- // 上一条消息(时间更早)是 index - 1
- const prevItem = this.currentRenderList[index - 1];
- if (!prevItem) return true;
-
- return (item.sendTime - prevItem.sendTime) > 5 * 60 * 1000;
- }
- },
-
- getTimeGap(message, index) {
- // 获取用于显示的时间内容
- // 逻辑与 shouldShowTime 配套
-
- // 无论哪种模式,我们显示的都是当前消息 message.sendTime 的格式化时间
- // 区别仅在于它是基于和哪条消息对比得出的显示结论
-
- const sendTime = message.sendTime;
- const now = dayjs();
- const messageDate = dayjs(sendTime);
-
- if (messageDate.isSame(now, 'day')) {
- if (now.diff(messageDate, 'second') < 60) return '刚刚';
- return messageDate.format('HH:mm');
- }
- if (messageDate.isSame(now.subtract(1, 'day'), 'day')) {
- return '昨天 ' + messageDate.format('HH:mm');
- }
- if (messageDate.isSame(now, 'year')) {
- return messageDate.format('MM-DD HH:mm');
- }
- return messageDate.format('YYYY-MM-DD HH:mm');
- },
-
-
- onHeaderBack() {
- this.setPageListener();
- this.loadMessageList(false);
- },
-
- setPageListener() {
- uni.$on('sendMessage', () => this.scrollToBottom());
- IMSDK.subscribe('onRecvC2CReadReceipt', ({ data }) => console.log('onRecvC2CReadReceipt', data));
- uni.$on(PageEvents.TypingUpdate, this.typingHandler);
- uni.$on(PageEvents.OnlineStateCheck, this.onlineCheckHandler);
- uni.$on(PageEvents.ScrollToBottom, this.scrollToBottom);
- uni.$on(PageEvents.AtSomeOne, this.atSomeOneHandler);
- uni.$on(PageEvents.MutipleCheckUpdate, this.mutipleCheckUpdateHandler);
- uni.$on(PageEvents.RtcCall, this.rtcInvitePrepare);
- uni.$on(PageEvents.NativeCallEnd, this.nativeCallEndHandler);
- },
-
- disposePageListener() {
- uni.$off(PageEvents.TypingUpdate, this.typingHandler);
- uni.$off(PageEvents.OnlineStateCheck, this.onlineCheckHandler);
- uni.$off(PageEvents.ScrollToBottom, this.scrollToBottom);
- uni.$off(PageEvents.AtSomeOne, this.atSomeOneHandler);
- uni.$off(PageEvents.MutipleCheckUpdate, this.mutipleCheckUpdateHandler);
- uni.$off(PageEvents.RtcCall, this.rtcInvitePrepare);
- uni.$off(PageEvents.NativeCallEnd, this.nativeCallEndHandler);
- },
- // ... 保留其他业务逻辑方法 ...
- onEnterSubPage() {
- this.isEnterSubPage = true;
- },
- onListClick() {
- uni.hideKeyboard();
- this.menuOutsideFlag += 1;
- this.footerOutsideFlag += 1;
- if (this.menuData.visible) {
- this.closeMenuGlobal();
- }
- },
- nativeCallEndHandler() {
- if (typeof plus === 'undefined' || !this.currentConversation.groupID) return;
- IMSDK.asyncApi('signalingGetRoomByGroupID', IMSDK.uuid(), this.currentConversation.groupID).then(({ data }) => {
- if (this.$refs.chatingHeaderRef) {
- this.$refs.chatingHeaderRef.updateCallingData(data.invitation ? data : null);
- }
- });
- },
- checkCalling() {
- if (typeof plus === 'undefined' || !this.currentConversation.groupID) return;
- if (this.timer) clearInterval(this.timer);
- this.nativeCallEndHandler();
- this.timer = setInterval(() => this.nativeCallEndHandler(), 10000);
- },
- mutipleRemove() {
- const checkedMessages = this.storeHistoryMessageList.filter((message) => message.checked);
- if (checkedMessages.length === 0) {
- uni.$u.toast('请先选择要操作的消息');
- return;
- }
- let count = 0;
- checkedMessages.map((message) => {
- IMSDK.asyncApi(IMSDK.IMMethods.DeleteMessage, IMSDK.uuid(), {
- conversationID: this.currentConversation.conversationID,
- clientMsgID: message.clientMsgID
- }).then(() => {
- count++;
- if (count === checkedMessages.length) {
- this.deleteMessages(checkedMessages);
- this.mutipleCheckUpdateHandler({ flag: false });
- uni.$u.toast('删除成功');
- }
- });
- });
- },
- forward(type) {
- const checkedMessages = this.storeHistoryMessageList.filter((message) => message.checked);
- if (checkedMessages.length === 0) {
- uni.$u.toast('请先选择要操作的消息');
- return;
- }
- const currentConversation = this.currentConversation;
- const title = currentConversation.groupID ? `群聊${currentConversation.showName}的聊天记录` : `和${currentConversation.showName}的聊天记录`;
- const mergeInfo = {
- messageList: [...checkedMessages],
- summaryList: checkedMessages.map((message) => `${message.senderNickname}:${parseMessageByType(message)}`),
- title
- };
- uni.navigateTo({
- url: `/pages_im/pages/common/contactChoose/index?type=${type}&mergeInfo=${encodeURIComponent(JSON.stringify(mergeInfo))}`
- });
- },
- closeMenuGlobal() {
- this.menuData.visible = false;
- this.menuData.message = null;
- },
- handleLongPress(data) {
- uni.hideKeyboard();
- this.menuData = {
- visible: true,
- message: data.message,
- rect: data.rect,
- isSender: data.isSender,
- paterWidth: data.rect.width
- };
- },
- typingHandler() {
- if (this.$refs.chatingHeaderRef) {
- this.$refs.chatingHeaderRef.updateTyping();
- }
- },
- onlineCheckHandler() {
- if (this.$refs.chatingHeaderRef) {
- this.$refs.chatingHeaderRef.checkOnline();
- }
- },
- atSomeOneHandler({ userID, nickname }) {
- // 震动反馈
- if (typeof plus !== 'undefined') {
- if (plus.os.name == 'iOS') {
- var UIImpactFeedbackGenerator = plus.ios.importClass('UIImpactFeedbackGenerator');
- var impact = new UIImpactFeedbackGenerator();
- impact.prepare();
- impact.init(1);
- impact.impactOccurred();
- } else {
- uni.vibrateLong();
- }
- } else {
- uni.vibrateLong();
- }
- if (this.$refs.chatingFooterRef) {
- this.$refs.chatingFooterRef.insertAt(userID, nickname);
- }
- },
- mutipleCheckUpdateHandler({ flag, message }) {
- this.mutipleCheckVisible = flag;
- if (message) {
- this.$store.dispatch('message/updateOneMessage', {
- message: { ...message, checked: true }
- });
- }
- if (!flag) {
- const tmpMessageList = [...this.storeHistoryMessageList];
- tmpMessageList.map((message) => (message.checked = false));
- this.$store.commit('message/SET_HISTORY_MESSAGE_LIST', tmpMessageList);
- }
- },
- mutipleCheckUpdate() {
- this.mutipleCheckUpdateHandler({ flag: false });
- },
- rtcInvitePrepare(mediaType) {
- this.chooseCallMediaType(mediaType).then((callMediaType) => {
- if (this.currentConversation.userID) {
- this.sendRtcInvite(callMediaType);
- } else {
- uni.$u.route('/pages_im/pages/conversation/groupMemberList/index', {
- type: GroupMemberListTypes.CallInvite,
- groupID: this.currentConversation.groupID,
- callMediaType: callMediaType
- });
- }
- });
- },
- chooseCallMediaType(mediaType) {
- return new Promise((resolve, reject) => {
- if (mediaType) {
- resolve(mediaType);
- return;
- }
- uni.showActionSheet({
- itemList: ['语音通话', '视频通话'],
- success: ({ tapIndex }) => {
- resolve(tapIndex ? 'video' : 'audio');
- },
- fail: () => reject()
- });
- });
- },
- sendRtcInvite(mediaType, userIDList) {
- const isVideo = mediaType === 'video';
- const inviteeUserIDList = userIDList ?? [this.currentConversation.userID];
- callingModule.startLiveChat(isVideo, inviteeUserIDList, this.currentConversation.groupID, this.storeCurrentUserID);
- },
- onNavigationBarButtonTap(e) {
- if (e?.id === 'more' || e?.index === 0) {
- this.onEnterSubPage();
- const isSingle = this.currentConversation?.conversationType === SessionType.Single;
- const url = isSingle ? '/pages_im/pages/conversation/singleSettings/index' : '/pages_im/pages/conversation/groupSettings/index';
- uni.navigateTo({ url });
- }
- }
- }
- };
- </script>
- <style scoped>
- .inverted {
- transform: rotate(180deg);
- }
- .inverted-content {
- transform: rotate(180deg);
- }
- .chating_container {
- flex: 1;
- width: 750rpx;
- background-color: #F5F7FA;
- position: relative;
- flex-direction: column;
- }
- .chat_content_wrap {
- flex: 1;
- width: 750rpx;
- position: relative;
- }
- .chat-list {
- flex: 1;
- width: 750rpx;
- background-color: #F5F7FA;
- }
- .page_loading_container {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: #F5F7FA;
- align-items: center;
- justify-content: center;
- z-index: 999;
- }
- .top_loader_container {
- align-items: center;
- justify-content: center;
- height: 40px;
- width: 750rpx;
- flex-direction: row;
- }
- .top_loader_indicator {
- height: 20px;
- width: 20px;
- color: #888;
- }
- .no_more_text {
- font-size: 14px;
- color: #999999;
- padding: 10px 0;
- }
- .time_gap_line {
- padding: 20rpx 0;
- align-items: center;
- justify-content: center;
- }
- .time_gap_text {
- font-size: 18px;
- color: #A5A5A5;
- background-color: transparent;
- }
- .mutiple_action_container {
- flex-direction: row;
- border-top-width: 1px;
- border-top-style: solid;
- border-top-color: #eaebed;
- background-color: #f0f2f6;
- justify-content: space-evenly;
- padding: 12px 24px;
- }
- .action_item {
- align-items: center;
- justify-content: center;
- }
- .action_icon {
- width: 48px;
- height: 48px;
- }
- .action_text {
- margin-top: 6px;
- font-size: 14px;
- color: #333;
- }
- .action_text_red {
- color: #ff381f;
- }
- .loading_overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: transparent;
- align-items: center;
- justify-content: center;
- }
- .loading_indicator {
- width: 30px;
- height: 30px;
- color: #999;
- }
- .page_loading_container {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- justify-content: center;
- align-items: center;
- background-color: #F5F7FA;
- }
- .page_loading_indicator {
- height: 60rpx;
- width: 60rpx;
- color: #999;
- }
- .page_loading_text {
- margin-top: 20rpx;
- font-size: 28rpx;
- color: #999;
- }
- </style>
|