index.nvue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086
  1. <template>
  2. <view class="chating_container">
  3. <!-- <view style="margin:100px;">1111</view> -->
  4. <chating-header ref="chatingHeaderRef" style="z-index: 100;" :storeCurrentConversation="currentConversation" :mutipleCheckVisible="mutipleCheckVisible" @mutipleCheckUpdate="mutipleCheckUpdate" @back="onHeaderBack" @enterSubPage="onEnterSubPage"></chating-header>
  5. <view class="chat_content_wrap">
  6. <list v-if="renderContent" class="chat-list" :class="[isInvertedMode ? 'inverted' : '']" ref="list" :show-scrollbar="false" @click="onListClick" :style="{opacity: isLayoutReady ? 1 : 0}">
  7. <!-- Normal Mode: Top Loader (History) -->
  8. <cell ref="normal-top-loader" @appear="onTopLoaderAppear" v-if="!isInvertedMode && storeHasMoreMessage && storeHistoryMessageList.length >= 10 && !initLoading" class="normal-loader">
  9. <view class="top_loader_container">
  10. <loading-indicator class="top_loader_indicator" :animating="true"></loading-indicator>
  11. </view>
  12. </cell>
  13. <!-- Inverted Mode: Bottom Anchor (Visual Bottom / Physical Top) -->
  14. <cell v-if="isInvertedMode" ref="invertedBottomAnchor">
  15. <view style="height: 1px; width: 750rpx;"></view>
  16. </cell>
  17. <cell v-for="(item, index) in currentRenderList" :key="item.clientMsgID" :ref="'msg-' + (item.clientMsgID || index)">
  18. <view :class="[isInvertedMode ? 'inverted-content' : '']">
  19. <!-- 预加载触发器:当渲染到列表第 5 条消息时触发(实际上是倒数第5条,因为是历史消息) -->
  20. <!-- 注意:Normal 模式下 index 0 是最旧的消息,index 5 是较旧的消息 -->
  21. <!-- 我们希望用户向上滚动接近顶部时触发,即接近 index 0 -->
  22. <!-- 所以当 index === 5 的元素出现时,说明用户已经滚到了很上面 -->
  23. <view v-if="index === 5 && !isInvertedMode" @appear="onPreloadAppear" style="height: 1px; width: 1px; opacity: 0; position: absolute;"></view>
  24. <view v-if="isInvertedMode && index === currentRenderList.length - 5" @appear="onPreloadAppear" style="height: 1px; width: 1px; opacity: 0; position: absolute;"></view>
  25. <view v-if="shouldShowTime(item, index) && !isInvertedMode" class="time_gap_line">
  26. <text class="time_gap_text">{{ getTimeGap(item, index) }}</text>
  27. </view>
  28. <message-item-render
  29. :source="item"
  30. :senderFaceUrl="item.senderFaceUrl"
  31. :senderNickname="item.senderNickname"
  32. :isSender="item.sendID === storeCurrentUserID"
  33. :mutipleCheckVisible="mutipleCheckVisible"
  34. @longPress="handleLongPress"
  35. @enterSubPage="onEnterSubPage">
  36. </message-item-render>
  37. <view v-if="shouldShowTime(item, index) && isInvertedMode" class="time_gap_line">
  38. <text class="time_gap_text">{{ getTimeGap(item, index) }}</text>
  39. </view>
  40. </view>
  41. </cell>
  42. <!-- Normal Mode: Bottom Anchor -->
  43. <cell v-if="!isInvertedMode" ref="normalBottomAnchor">
  44. <view style="height: 1px; width: 750rpx;"></view>
  45. </cell>
  46. <!-- Inverted Mode: Top Loader (Visual Top / Physical Bottom) -->
  47. <cell ref="inverted-top-loader" @appear="onTopLoaderAppear" v-if="isInvertedMode && storeHasMoreMessage && !initLoading">
  48. <view class="top_loader_container" :class="[isInvertedMode ? 'inverted-content' : '']">
  49. <loading-indicator class="top_loader_indicator" :animating="true"></loading-indicator>
  50. </view>
  51. </cell>
  52. </list>
  53. <view v-if="initLoading" class="page_loading_container" @touchmove.stop.prevent>
  54. <!-- <loading-indicator class="page_loading_indicator" :animating="true"></loading-indicator> -->
  55. <!-- <text class="page_loading_text">加载中...</text> -->
  56. </view>
  57. </view>
  58. <chating-footer ref="chatingFooterRef" :footerOutsideFlag="footerOutsideFlag" @scrollToBottom="scrollToBottom" @enterSubPage="onEnterSubPage" v-if="!mutipleCheckVisible"></chating-footer>
  59. <view v-if="mutipleCheckVisible" class="mutiple_action_container">
  60. <view @click="forward('MergeForWard')" class="action_item">
  61. <image class="action_icon" src="/pages_im/static/images/mutiple_merge.png" />
  62. <text class="action_text">合并转发</text>
  63. </view>
  64. <view @click="mutipleRemove" class="action_item">
  65. <image class="action_icon" src="/pages_im/static/images/mutiple_delete.png" />
  66. <text class="action_text action_text_red">删除</text>
  67. </view>
  68. </view>
  69. <message-menu
  70. v-if="menuData.visible"
  71. :message="menuData.message"
  72. :isSender="menuData.isSender"
  73. :paterWidth="menuData.paterWidth"
  74. :rect="menuData.rect"
  75. @close="closeMenuGlobal"
  76. @enterSubPage="onEnterSubPage"/>
  77. </view>
  78. </template>
  79. <script>
  80. import '../../../util/polyfill-nvue';
  81. import { mapGetters, mapActions } from 'vuex';
  82. import IMSDK from 'openim-uniapp-polyfill';
  83. import { MessageStatus, SessionType, MessageType } from '@/pages_im/constant/imConstants';
  84. import { ContactChooseTypes, GroupMemberListTypes, PageEvents, SendMessageFailedType } from '../../../constant';
  85. import ChatingHeader from './components/ChatingHeader.vue';
  86. import ChatingFooter from './components/ChatingFooter/index.vue';
  87. import MessageItemRender from './components/MessageItem/index.vue';
  88. import MessageMenu from './components/MessageItem/MessageMenu.vue';
  89. import dayjs from 'dayjs';
  90. import { markConversationAsRead, parseMessageByType, callingModule, getIMSDK } from '../../../util/imCommon';
  91. const dom = uni.requireNativePlugin('dom');
  92. // 封装获取节点矩形信息的工具函数
  93. export default {
  94. components: {
  95. ChatingHeader,
  96. ChatingFooter,
  97. MessageItemRender,
  98. MessageMenu
  99. },
  100. data() {
  101. return {
  102. menuData: {
  103. visible: false,
  104. message: null,
  105. rect: {},
  106. isSender: false,
  107. paterWidth: 0
  108. },
  109. renderContent: true,
  110. footerOutsideFlag: 0,
  111. menuOutsideFlag: 0,
  112. initLoading: true,
  113. mutipleCheckVisible: false,
  114. back2Tab: false,
  115. timer: null,
  116. refreshing: false,
  117. messageLoadState: {
  118. loading: false
  119. },
  120. canShowBot: false,
  121. conversationID: '',
  122. localConversation: null,
  123. isBackAction: false,
  124. isEnterSubPage: false,
  125. loadingStartTime: 0,
  126. layoutRetryCount: 0,
  127. stabilityCounter: 0,
  128. layoutTimer: null,
  129. isLayoutReady: false,
  130. isInvertedMode: false, // 默认使用 Normal Mode,避免少消息时初始倒置导致靠底
  131. checkModeTimer: null,
  132. isHistoryLoading: false, // 标记是否正在加载历史消息
  133. historyAnchorID: '' // 加载历史消息时的定位锚点ID
  134. };
  135. },
  136. computed: {
  137. ...mapGetters([
  138. 'timStore',
  139. 'storeHistoryMessageList',
  140. 'storeHasMoreMessage',
  141. 'storeCurrentUserID',
  142. 'storeFriendList',
  143. 'storeCurrentGroup'
  144. ]),
  145. currentConversation() {
  146. return this.localConversation || {};
  147. },
  148. imType() {
  149. return this.timStore?.imType || '';
  150. },
  151. // 反转消息列表:[最新消息, ..., 最旧消息]
  152. reversedMessageList() {
  153. // 确保按时间倒序排列:时间越大(越新)越靠前
  154. // 配合 Inverted Mode,index 0 (最新) 在视觉底部,index N (最旧) 在视觉顶部
  155. return [...this.storeHistoryMessageList].sort((a, b) => (a.sendTime || 0) - (b.sendTime || 0)).reverse();
  156. },
  157. currentRenderList() {
  158. if (this.isInvertedMode) {
  159. return this.reversedMessageList;
  160. }
  161. // Normal Mode: 按时间正序排列 (Old -> New)
  162. return [...this.storeHistoryMessageList].sort((a, b) => (a.sendTime || 0) - (b.sendTime || 0));
  163. }
  164. },
  165. watch: {
  166. storeHistoryMessageList: {
  167. handler(newVal, oldVal) {
  168. // 如果正在加载历史消息,不触发自动滚动
  169. if (this.isHistoryLoading) return;
  170. // 只有当有新消息新增(即列表末尾元素变化)时,才滚动到底部
  171. // 避免加载历史消息(列表头部变化)时触发滚动
  172. const newLast = newVal && newVal.length > 0 ? newVal[newVal.length - 1] : null;
  173. const oldLast = oldVal && oldVal.length > 0 ? oldVal[oldVal.length - 1] : null;
  174. if (!oldLast || (newLast && newLast.clientMsgID !== oldLast.clientMsgID)) {
  175. // 新消息来了,触发滚动
  176. this.triggerModeCheck();
  177. }
  178. },
  179. }
  180. },
  181. onShow() {
  182. try {
  183. const app = getApp();
  184. if (app && app.globalData) {
  185. if (app.globalData.IM_back2Tab !== undefined) {
  186. this.back2Tab = app.globalData.IM_back2Tab;
  187. }
  188. if (app.globalData.IM_CURRENT_CONVERSATION) {
  189. const newConv = app.globalData.IM_CURRENT_CONVERSATION;
  190. // 检查是否需要重新初始化会话
  191. // 只要页面可见,且会话ID一致,就必须确保页面可见性状态正确
  192. if (this.conversationID === newConv.conversationID) {
  193. // 同一会话,无需重新加载,直接确保显示即可
  194. // 如果之前被隐藏了(虽然现在我们不再隐藏),这里做个双保险
  195. this.initLoading = false;
  196. this.isLayoutReady = true;
  197. this.renderContent = true;
  198. // 恢复监听器
  199. this.setPageListener();
  200. // 刷新一下未读状态等
  201. this.checkCalling();
  202. let userID = this.currentConversation?.userID;
  203. this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
  204. } else if (!this.conversationID || this.conversationID !== newConv.conversationID) {
  205. // 不同会话,执行完整初始化
  206. this.handleConversationInit(newConv);
  207. }
  208. }
  209. }
  210. } catch (e) {
  211. console.error('onShow error', e);
  212. }
  213. },
  214. onLoad(options) {
  215. const app = getApp();
  216. let conversation = null;
  217. // 1. 尝试从 GlobalData 获取
  218. if (app && app.globalData && app.globalData.IM_CURRENT_CONVERSATION) {
  219. conversation = app.globalData.IM_CURRENT_CONVERSATION;
  220. if (app.globalData.IM_back2Tab !== undefined) {
  221. this.back2Tab = app.globalData.IM_back2Tab;
  222. }
  223. }
  224. // 2. 尝试从 Storage 获取 (兜底)
  225. if (!conversation) {
  226. try {
  227. const storageData = uni.getStorageSync('IM_CURRENT_CONVERSATION');
  228. if (storageData) {
  229. conversation = storageData;
  230. }
  231. } catch (e) {}
  232. }
  233. if (conversation) {
  234. this.localConversation = conversation;
  235. this.conversationID = conversation.conversationID;
  236. this.renderContent = true;
  237. this.initLoading = true; // 保持 Loading 直到消息加载并计算完布局
  238. // 同步到 Store
  239. this.$store.commit('conversation/SET_CURRENT_CONVERSATION', conversation);
  240. // 如果是工作群,获取群信息
  241. if (conversation.conversationType === SessionType.WorkingGroup) {
  242. this.$store.dispatch("conversation/getCurrentGroup", conversation.groupID);
  243. this.$store.dispatch("conversation/getCurrentMemberInGroup", conversation.groupID);
  244. }
  245. }
  246. this.initLogic();
  247. this.checkCalling();
  248. let userID = this.currentConversation?.userID;
  249. this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
  250. // 监听预加载事件
  251. uni.$on('IM_PRELOAD_CONVERSATION', this.preloadConversation);
  252. },
  253. onHide() {
  254. // 当页面隐藏时(如返回上一页),重置部分状态
  255. // 但不要重置 conversationID,因为下次进来可能还是同一个会话
  256. // 关键:必须允许重新加载
  257. this.messageLoadState.loading = false;
  258. if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
  259. // 优化:不要在 onHide 时隐藏内容,避免返回时白屏
  260. // this.isLayoutReady = false;
  261. this.disposePageListener();
  262. markConversationAsRead({...this.currentConversation}, true);
  263. if (this.timer) clearInterval(this.timer);
  264. },
  265. onUnload() {
  266. //uni.$off('IM_PRELOAD_CONVERSATION', this.preloadConversation);
  267. this.disposePageListener();
  268. markConversationAsRead({...this.currentConversation}, true);
  269. this.resetConversationState();
  270. this.resetMessageState();
  271. if (this.timer) clearInterval(this.timer);
  272. this.localConversation = null;
  273. this.conversationID = '';
  274. this.initLoading = true;
  275. this.renderContent = false;
  276. this.isInvertedMode = false; // 退出时重置为 Normal Mode
  277. },
  278. onBackPress() {
  279. if (this.mutipleCheckVisible) {
  280. this.mutipleCheckUpdateHandler({ flag: false });
  281. return true;
  282. }
  283. this.isBackAction = true;
  284. return false;
  285. },
  286. methods: {
  287. ...mapActions('message', ['resetMessageState', 'deleteMessages']),
  288. ...mapActions('conversation', ['resetConversationState']),
  289. /**
  290. * 处理会话初始化逻辑(复用于 onShow)
  291. */
  292. async handleConversationInit(newConv) {
  293. this.disposePageListener();
  294. // 内存优化:手动释放本地大对象引用
  295. this.localConversation = null;
  296. this.menuData.message = null;
  297. this.resetConversationState();
  298. // 性能优化:并行执行重置和加载,不强制等待 reset 完成,只要保证新数据覆盖即可
  299. // resetMessageState 主要是为了清空 store,防止旧数据闪现
  300. this.resetMessageState();
  301. this.localConversation = newConv;
  302. this.conversationID = newConv.conversationID;
  303. this.renderContent = true;
  304. this.initLoading = true; // 重新加载时显示 Loading
  305. this.isLayoutReady = false;
  306. this.loadingStartTime = Date.now();
  307. this.layoutRetryCount = 0;
  308. if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
  309. // 关键:重置本地加载状态
  310. this.messageLoadState.loading = false;
  311. this.isHistoryLoading = false;
  312. this.historyAnchorID = '';
  313. this.isInvertedMode = false; // 切换会话时重置为 Normal Mode
  314. this.$nextTick(() => {
  315. this.$store.commit('conversation/SET_CURRENT_CONVERSATION', this.localConversation);
  316. if (this.localConversation.conversationType === SessionType.WorkingGroup) {
  317. this.$store.dispatch("conversation/getCurrentGroup", this.localConversation.groupID);
  318. this.$store.dispatch("conversation/getCurrentMemberInGroup", this.localConversation.groupID);
  319. }
  320. // 立即启动加载,不再等待
  321. this.initLogic(false);
  322. let userID = this.currentConversation?.userID;
  323. this.canShowBot = this.isUserInFriendList(userID, this.storeFriendList);
  324. });
  325. },
  326. // --- 事件处理 onLoad emit---
  327. preloadConversation(newConv) {
  328. if (!newConv) return;
  329. // 清理旧的监听器,防止重复注册
  330. this.disposePageListener();
  331. this.localConversation = newConv;
  332. this.conversationID = newConv.conversationID;
  333. this.$store.commit('conversation/SET_CURRENT_CONVERSATION', newConv);
  334. // 确保先清空消息状态,再进行后续操作
  335. this.resetMessageState().then(() => {
  336. // 重置渲染状态,强制列表重新渲染,避免视图残留或 DOM 异常
  337. this.renderContent = false;
  338. // 关键:重置本地加载状态,防止因上一次加载未完成而导致死锁
  339. this.messageLoadState.loading = false;
  340. this.isHistoryLoading = false;
  341. this.historyAnchorID = '';
  342. this.isInvertedMode = false; // 预加载时重置为 Normal Mode
  343. this.initLoading = true;
  344. this.isLayoutReady = false;
  345. this.loadingStartTime = Date.now();
  346. this.layoutRetryCount = 0;
  347. if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
  348. if (this.localConversation.conversationType === SessionType.WorkingGroup) {
  349. this.$store.dispatch("conversation/getCurrentGroup", this.localConversation.groupID);
  350. this.$store.dispatch("conversation/getCurrentMemberInGroup", this.localConversation.groupID);
  351. }
  352. this.$nextTick(() => {
  353. this.renderContent = true;
  354. this.initLogic(false);
  355. });
  356. });
  357. },
  358. /**
  359. * 页面初始化主逻辑
  360. */
  361. async initLogic(shouldReset = true) {
  362. if (shouldReset) {
  363. await this.resetMessageState();
  364. this.initLoading = true;
  365. this.isLayoutReady = false;
  366. this.loadingStartTime = Date.now();
  367. this.layoutRetryCount = 0;
  368. this.isInvertedMode = false; // 初始化时重置为 Normal Mode
  369. if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
  370. }
  371. this.setPageListener();
  372. this.loadMessageList(false);
  373. // 兜底:5秒后强制结束 loading
  374. setTimeout(() => {
  375. if (this.initLoading) {
  376. this.initLoading = false;
  377. }
  378. }, 5000);
  379. },
  380. /**
  381. * 加载消息列表
  382. * @param {boolean} isHistory 是否加载历史消息
  383. */
  384. loadMessageList(isHistory = false) {
  385. if (this.messageLoadState.loading) return;
  386. // 预加载命中优化
  387. if (!isHistory && this.storeHistoryMessageList.length > 0) {
  388. // 先滚动到底部,且不显示页面(保持透明)
  389. this.$nextTick(() => {
  390. this.scrollToBottom();
  391. // 延迟一点点再显示页面,确保滚动已经完成,避免视觉跳动
  392. setTimeout(() => {
  393. this.isLayoutReady = true;
  394. this.initLoading = false;
  395. }, 50);
  396. });
  397. return;
  398. }
  399. this.messageLoadState.loading = true;
  400. // 标记正在加载历史消息,防止 watch 触发滚动到底部
  401. if (isHistory) {
  402. this.isHistoryLoading = true;
  403. }
  404. const conversationID = this.conversationID || this.currentConversation?.conversationID;
  405. if (!conversationID) {
  406. this.messageLoadState.loading = false;
  407. this.initLoading = false;
  408. this.isHistoryLoading = false;
  409. return;
  410. }
  411. const startClientMsgID = isHistory && this.storeHistoryMessageList.length > 0
  412. ? this.storeHistoryMessageList[0].clientMsgID : '';
  413. this.$store.dispatch('message/getHistoryMesageList', {
  414. conversationID,
  415. startClientMsgID,
  416. count: 20
  417. }).then(res => {
  418. this.messageLoadState.loading = false;
  419. if (!isHistory) {
  420. // 强制使用 Normal 模式 (从上往下),解决小屏倒置问题
  421. // 除非消息数量极大(>1000),否则不使用 Inverted 模式
  422. // 当消息数量不足 10 条时,使用 Normal 模式
  423. // 需求:当超过10条时,使用倒置模式,消息从底部往上排列(时间越大越靠底)
  424. if (this.storeHistoryMessageList.length < 10) {
  425. this.isInvertedMode = false;
  426. // Normal Mode (Top Aligned)
  427. // 不进行滚动,直接显示
  428. this.initLoading = false;
  429. this.isLayoutReady = true;
  430. } else {
  431. this.isInvertedMode = true;
  432. // Inverted Mode (Bottom Aligned)
  433. // 优化:Inverted List 的物理顶部就是视觉底部,且数据是按时间倒序排列(最新在Index 0)
  434. // 所以渲染完成后,默认位置就是最新消息处,无需手动滚动,直接显示,消除闪烁和白屏等待。
  435. this.$nextTick(() => {
  436. this.initLoading = false;
  437. this.isLayoutReady = true;
  438. });
  439. }
  440. } else {
  441. if (this.isInvertedMode) {
  442. // 加载历史消息:无感保持位置
  443. // Inverted Mode 下,新消息添加在列表末尾(物理底部),
  444. // 原有的列表内容位置不变,用户只需继续滚动即可看到新内容
  445. // 不需要手动 scrollToElement,否则会导致抖动
  446. this.$nextTick(() => {
  447. // 延迟一点点确保渲染完成后再允许 watch 触发
  448. setTimeout(() => {
  449. this.isHistoryLoading = false;
  450. this.historyAnchorID = ''; // 清除锚点
  451. }, 50);
  452. });
  453. } else {
  454. // Normal Mode 下加载历史消息,需要手动滚动到锚点
  455. const anchorID = this.historyAnchorID || startClientMsgID;
  456. if (anchorID) {
  457. this.$nextTick(() => {
  458. const targetRef = 'msg-' + anchorID;
  459. const el = this.$refs[targetRef];
  460. if (el && Array.isArray(el) && el.length > 0) {
  461. dom.scrollToElement(el[0], { animated: false, offset: 0 });
  462. }
  463. setTimeout(() => {
  464. this.isHistoryLoading = false;
  465. this.historyAnchorID = ''; // 清除锚点
  466. }, 50);
  467. });
  468. } else {
  469. this.isHistoryLoading = false;
  470. }
  471. }
  472. }
  473. // 只有非历史消息(即初始加载或发送新消息)才触发模式检查和自动滚动
  474. // 加载历史消息时,我们已经手动处理了位置保持,绝对不能再触发自动滚动
  475. if (!isHistory) {
  476. if (this.storeHistoryMessageList.length <= 20) { // 简单判断是否初始
  477. this.triggerModeCheck();
  478. }
  479. }
  480. }).catch(err => {
  481. this.messageLoadState.loading = false;
  482. this.initLoading = false;
  483. this.isHistoryLoading = false;
  484. console.error("加载消息失败", err);
  485. });
  486. },
  487. /**
  488. * 触发模式检查
  489. */
  490. triggerModeCheck() {
  491. if (this.checkModeTimer) clearTimeout(this.checkModeTimer);
  492. this.checkModeTimer = setTimeout(() => {
  493. this.checkLayoutMode();
  494. }, 50);
  495. },
  496. /**
  497. * 检查并切换布局模式
  498. * Normal Mode: 如果内容超过一屏,切换到 Inverted Mode
  499. * Inverted Mode: 保持不变 (除非清空消息,这里暂不处理)
  500. */
  501. async checkLayoutMode() {
  502. // 仅用于触发滚动和结束Loading
  503. // 如果是初始加载,不要在这里立即结束 Loading,交给 loadMessageList 的回调处理
  504. // 避免滚动还没完成就显示了
  505. if (!this.initLoading && !this.isInvertedMode) {
  506. this.scrollToBottom();
  507. }
  508. },
  509. tryStopLoading(delay = 100) {
  510. if (this.initLoading) {
  511. // 计算剩余需要的等待时间,确保复杂消息至少有一定渲染时间
  512. const now = Date.now();
  513. const lastItem = this.reversedMessageList[0]; // 注意:reversedList[0] 是最新消息
  514. // 即使是普通文本消息,也给予至少 500ms 的 Loading 时间
  515. // 复杂消息给予 1000ms
  516. const minDuration = this.isComplexMessage(lastItem) ? 200 : 0;
  517. const elapsed = now - (this.loadingStartTime || 0);
  518. const remaining = Math.max(0, minDuration - elapsed);
  519. const finalDelay = Math.max(delay, remaining);
  520. // 清除之前的定时器,防止过早结束
  521. if (this.stopLoadingTimer) clearTimeout(this.stopLoadingTimer);
  522. // 延迟结束 Loading,确保布局调整后的渲染已上屏
  523. this.stopLoadingTimer = setTimeout(() => {
  524. this.initLoading = false;
  525. this.stopLoadingTimer = null;
  526. // 只有当 Loading 真正结束时,才允许显示列表
  527. this.$nextTick(() => {
  528. this.isLayoutReady = true;
  529. });
  530. }, finalDelay);
  531. }
  532. },
  533. scrollToBottom() {
  534. this.$nextTick(() => {
  535. if (this.isInvertedMode) {
  536. // Inverted Mode: 滚动到 Visual Bottom (Physical Top)
  537. // 我们的模板中有一个专门的 anchor: invertedBottomAnchor
  538. const bottomAnchor = this.$refs['invertedBottomAnchor'];
  539. if (bottomAnchor) {
  540. dom.scrollToElement(bottomAnchor, { animated: false, offset: 0 });
  541. } else {
  542. // 兜底:滚动到第一条数据 (最新消息)
  543. if (this.currentRenderList.length > 0) {
  544. const firstItem = this.currentRenderList[0];
  545. const refName = 'msg-' + (firstItem.clientMsgID || 0);
  546. const el = this.$refs[refName];
  547. if (el && Array.isArray(el) && el.length > 0) {
  548. dom.scrollToElement(el[0], { animated: false, offset: 0 });
  549. }
  550. }
  551. }
  552. return;
  553. }
  554. // Normal Mode: 滚动到最后一条消息
  555. // 优先滚动到最后一条消息,比滚动到锚点更精准
  556. const listLen = this.currentRenderList.length;
  557. if (listLen > 0) {
  558. const lastItem = this.currentRenderList[listLen - 1];
  559. // 确保 ref key 与模板中一致:'msg-' + (item.clientMsgID || index)
  560. const refName = 'msg-' + (lastItem.clientMsgID || (listLen - 1));
  561. const el = this.$refs[refName];
  562. if (el && Array.isArray(el) && el.length > 0) {
  563. dom.scrollToElement(el[0], { animated: false, offset: 0 });
  564. return;
  565. }
  566. }
  567. // 兜底:滚动到锚点
  568. const anchorName = 'normalBottomAnchor';
  569. const bottomAnchor = this.$refs[anchorName];
  570. if (bottomAnchor) {
  571. dom.scrollToElement(bottomAnchor, { animated: false, offset: 0 });
  572. }
  573. });
  574. },
  575. isComplexMessage(item) {
  576. if (!item) return false;
  577. // 图片、视频、文件等视为复杂消息
  578. const type = item.type;
  579. return type === 'IMG' || type === 'VIDEO' || type === 'FILE' || type === 'CUSTOM';
  580. },
  581. onTopLoaderAppear() {
  582. if (this.initLoading || this.messageLoadState.loading || !this.storeHasMoreMessage) return;
  583. // 增加防抖
  584. if (this.preloadTimer) clearTimeout(this.preloadTimer);
  585. this.preloadTimer = setTimeout(() => {
  586. // 记录当前顶部消息ID作为锚点
  587. if (this.currentRenderList.length > 0) {
  588. this.historyAnchorID = this.currentRenderList[0].clientMsgID;
  589. }
  590. this.loadMessageList(true);
  591. }, 100);
  592. },
  593. onPreloadAppear() {
  594. // 预加载触发
  595. if (this.messageLoadState.loading || !this.storeHasMoreMessage) return;
  596. // 增加防抖
  597. if (this.preloadTimer) clearTimeout(this.preloadTimer);
  598. this.preloadTimer = setTimeout(() => {
  599. // 记录触发点(Index 5)作为锚点,避免大幅跳动
  600. if (this.currentRenderList.length > 5) {
  601. this.historyAnchorID = this.currentRenderList[5].clientMsgID;
  602. } else if (this.currentRenderList.length > 0) {
  603. this.historyAnchorID = this.currentRenderList[0].clientMsgID;
  604. }
  605. this.loadMessageList(true);
  606. }, 100);
  607. },
  608. // --- 辅助函数 ---
  609. isUserInFriendList(userID, friendList) {
  610. if (!friendList) return false;
  611. return friendList.some(f => f.userID === userID);
  612. },
  613. /**
  614. * 判断是否显示时间
  615. * @param {Object} item 当前消息
  616. * @param {Number} index 当前消息在 reversedMessageList 中的索引
  617. */
  618. shouldShowTime(item, index) {
  619. if (this.isInvertedMode) {
  620. // Inverted Mode (New -> Old)
  621. // 视觉最顶部是列表最后一条 (index = length - 1)
  622. // 但是因为是倒置列表,我们遍历是从 index 0 开始(视觉最底部)
  623. // 我们的目标是:如果当前消息 (index) 和上一条消息 (index+1, 更旧) 时间间隔大,则在当前消息上方显示时间
  624. // 1. 最旧的消息(视觉最顶部,列表最后一条)总是显示时间(如果它是第一条被加载的)
  625. if (index === this.currentRenderList.length - 1) return true;
  626. // 2. 比较当前消息 (index) 和更旧的消息 (index + 1)
  627. const prevItem = this.currentRenderList[index + 1];
  628. if (!prevItem) return true;
  629. // 注意:这里比较的是 当前消息时间 - 上一条(更旧)消息时间
  630. return (item.sendTime - prevItem.sendTime) > 5 * 60 * 1000;
  631. } else {
  632. // Normal Mode (Old -> New)
  633. // 视觉最顶部是列表第一条 (index = 0)
  634. if (index === 0) return true;
  635. // 上一条消息(时间更早)是 index - 1
  636. const prevItem = this.currentRenderList[index - 1];
  637. if (!prevItem) return true;
  638. return (item.sendTime - prevItem.sendTime) > 5 * 60 * 1000;
  639. }
  640. },
  641. getTimeGap(message, index) {
  642. // 获取用于显示的时间内容
  643. // 逻辑与 shouldShowTime 配套
  644. // 无论哪种模式,我们显示的都是当前消息 message.sendTime 的格式化时间
  645. // 区别仅在于它是基于和哪条消息对比得出的显示结论
  646. const sendTime = message.sendTime;
  647. const now = dayjs();
  648. const messageDate = dayjs(sendTime);
  649. if (messageDate.isSame(now, 'day')) {
  650. if (now.diff(messageDate, 'second') < 60) return '刚刚';
  651. return messageDate.format('HH:mm');
  652. }
  653. if (messageDate.isSame(now.subtract(1, 'day'), 'day')) {
  654. return '昨天 ' + messageDate.format('HH:mm');
  655. }
  656. if (messageDate.isSame(now, 'year')) {
  657. return messageDate.format('MM-DD HH:mm');
  658. }
  659. return messageDate.format('YYYY-MM-DD HH:mm');
  660. },
  661. onHeaderBack() {
  662. this.setPageListener();
  663. this.loadMessageList(false);
  664. },
  665. setPageListener() {
  666. uni.$on('sendMessage', () => this.scrollToBottom());
  667. IMSDK.subscribe('onRecvC2CReadReceipt', ({ data }) => console.log('onRecvC2CReadReceipt', data));
  668. uni.$on(PageEvents.TypingUpdate, this.typingHandler);
  669. uni.$on(PageEvents.OnlineStateCheck, this.onlineCheckHandler);
  670. uni.$on(PageEvents.ScrollToBottom, this.scrollToBottom);
  671. uni.$on(PageEvents.AtSomeOne, this.atSomeOneHandler);
  672. uni.$on(PageEvents.MutipleCheckUpdate, this.mutipleCheckUpdateHandler);
  673. uni.$on(PageEvents.RtcCall, this.rtcInvitePrepare);
  674. uni.$on(PageEvents.NativeCallEnd, this.nativeCallEndHandler);
  675. },
  676. disposePageListener() {
  677. uni.$off(PageEvents.TypingUpdate, this.typingHandler);
  678. uni.$off(PageEvents.OnlineStateCheck, this.onlineCheckHandler);
  679. uni.$off(PageEvents.ScrollToBottom, this.scrollToBottom);
  680. uni.$off(PageEvents.AtSomeOne, this.atSomeOneHandler);
  681. uni.$off(PageEvents.MutipleCheckUpdate, this.mutipleCheckUpdateHandler);
  682. uni.$off(PageEvents.RtcCall, this.rtcInvitePrepare);
  683. uni.$off(PageEvents.NativeCallEnd, this.nativeCallEndHandler);
  684. },
  685. // ... 保留其他业务逻辑方法 ...
  686. onEnterSubPage() {
  687. this.isEnterSubPage = true;
  688. },
  689. onListClick() {
  690. uni.hideKeyboard();
  691. this.menuOutsideFlag += 1;
  692. this.footerOutsideFlag += 1;
  693. if (this.menuData.visible) {
  694. this.closeMenuGlobal();
  695. }
  696. },
  697. nativeCallEndHandler() {
  698. if (typeof plus === 'undefined' || !this.currentConversation.groupID) return;
  699. IMSDK.asyncApi('signalingGetRoomByGroupID', IMSDK.uuid(), this.currentConversation.groupID).then(({ data }) => {
  700. if (this.$refs.chatingHeaderRef) {
  701. this.$refs.chatingHeaderRef.updateCallingData(data.invitation ? data : null);
  702. }
  703. });
  704. },
  705. checkCalling() {
  706. if (typeof plus === 'undefined' || !this.currentConversation.groupID) return;
  707. if (this.timer) clearInterval(this.timer);
  708. this.nativeCallEndHandler();
  709. this.timer = setInterval(() => this.nativeCallEndHandler(), 10000);
  710. },
  711. mutipleRemove() {
  712. const checkedMessages = this.storeHistoryMessageList.filter((message) => message.checked);
  713. if (checkedMessages.length === 0) {
  714. uni.$u.toast('请先选择要操作的消息');
  715. return;
  716. }
  717. let count = 0;
  718. checkedMessages.map((message) => {
  719. IMSDK.asyncApi(IMSDK.IMMethods.DeleteMessage, IMSDK.uuid(), {
  720. conversationID: this.currentConversation.conversationID,
  721. clientMsgID: message.clientMsgID
  722. }).then(() => {
  723. count++;
  724. if (count === checkedMessages.length) {
  725. this.deleteMessages(checkedMessages);
  726. this.mutipleCheckUpdateHandler({ flag: false });
  727. uni.$u.toast('删除成功');
  728. }
  729. });
  730. });
  731. },
  732. forward(type) {
  733. const checkedMessages = this.storeHistoryMessageList.filter((message) => message.checked);
  734. if (checkedMessages.length === 0) {
  735. uni.$u.toast('请先选择要操作的消息');
  736. return;
  737. }
  738. const currentConversation = this.currentConversation;
  739. const title = currentConversation.groupID ? `群聊${currentConversation.showName}的聊天记录` : `和${currentConversation.showName}的聊天记录`;
  740. const mergeInfo = {
  741. messageList: [...checkedMessages],
  742. summaryList: checkedMessages.map((message) => `${message.senderNickname}:${parseMessageByType(message)}`),
  743. title
  744. };
  745. uni.navigateTo({
  746. url: `/pages_im/pages/common/contactChoose/index?type=${type}&mergeInfo=${encodeURIComponent(JSON.stringify(mergeInfo))}`
  747. });
  748. },
  749. closeMenuGlobal() {
  750. this.menuData.visible = false;
  751. this.menuData.message = null;
  752. },
  753. handleLongPress(data) {
  754. uni.hideKeyboard();
  755. this.menuData = {
  756. visible: true,
  757. message: data.message,
  758. rect: data.rect,
  759. isSender: data.isSender,
  760. paterWidth: data.rect.width
  761. };
  762. },
  763. typingHandler() {
  764. if (this.$refs.chatingHeaderRef) {
  765. this.$refs.chatingHeaderRef.updateTyping();
  766. }
  767. },
  768. onlineCheckHandler() {
  769. if (this.$refs.chatingHeaderRef) {
  770. this.$refs.chatingHeaderRef.checkOnline();
  771. }
  772. },
  773. atSomeOneHandler({ userID, nickname }) {
  774. // 震动反馈
  775. if (typeof plus !== 'undefined') {
  776. if (plus.os.name == 'iOS') {
  777. var UIImpactFeedbackGenerator = plus.ios.importClass('UIImpactFeedbackGenerator');
  778. var impact = new UIImpactFeedbackGenerator();
  779. impact.prepare();
  780. impact.init(1);
  781. impact.impactOccurred();
  782. } else {
  783. uni.vibrateLong();
  784. }
  785. } else {
  786. uni.vibrateLong();
  787. }
  788. if (this.$refs.chatingFooterRef) {
  789. this.$refs.chatingFooterRef.insertAt(userID, nickname);
  790. }
  791. },
  792. mutipleCheckUpdateHandler({ flag, message }) {
  793. this.mutipleCheckVisible = flag;
  794. if (message) {
  795. this.$store.dispatch('message/updateOneMessage', {
  796. message: { ...message, checked: true }
  797. });
  798. }
  799. if (!flag) {
  800. const tmpMessageList = [...this.storeHistoryMessageList];
  801. tmpMessageList.map((message) => (message.checked = false));
  802. this.$store.commit('message/SET_HISTORY_MESSAGE_LIST', tmpMessageList);
  803. }
  804. },
  805. mutipleCheckUpdate() {
  806. this.mutipleCheckUpdateHandler({ flag: false });
  807. },
  808. rtcInvitePrepare(mediaType) {
  809. this.chooseCallMediaType(mediaType).then((callMediaType) => {
  810. if (this.currentConversation.userID) {
  811. this.sendRtcInvite(callMediaType);
  812. } else {
  813. uni.$u.route('/pages_im/pages/conversation/groupMemberList/index', {
  814. type: GroupMemberListTypes.CallInvite,
  815. groupID: this.currentConversation.groupID,
  816. callMediaType: callMediaType
  817. });
  818. }
  819. });
  820. },
  821. chooseCallMediaType(mediaType) {
  822. return new Promise((resolve, reject) => {
  823. if (mediaType) {
  824. resolve(mediaType);
  825. return;
  826. }
  827. uni.showActionSheet({
  828. itemList: ['语音通话', '视频通话'],
  829. success: ({ tapIndex }) => {
  830. resolve(tapIndex ? 'video' : 'audio');
  831. },
  832. fail: () => reject()
  833. });
  834. });
  835. },
  836. sendRtcInvite(mediaType, userIDList) {
  837. const isVideo = mediaType === 'video';
  838. const inviteeUserIDList = userIDList ?? [this.currentConversation.userID];
  839. callingModule.startLiveChat(isVideo, inviteeUserIDList, this.currentConversation.groupID, this.storeCurrentUserID);
  840. },
  841. onNavigationBarButtonTap(e) {
  842. if (e?.id === 'more' || e?.index === 0) {
  843. this.onEnterSubPage();
  844. const isSingle = this.currentConversation?.conversationType === SessionType.Single;
  845. const url = isSingle ? '/pages_im/pages/conversation/singleSettings/index' : '/pages_im/pages/conversation/groupSettings/index';
  846. uni.navigateTo({ url });
  847. }
  848. }
  849. }
  850. };
  851. </script>
  852. <style scoped>
  853. .inverted {
  854. transform: rotate(180deg);
  855. }
  856. .inverted-content {
  857. transform: rotate(180deg);
  858. }
  859. .chating_container {
  860. flex: 1;
  861. width: 750rpx;
  862. background-color: #F5F7FA;
  863. position: relative;
  864. flex-direction: column;
  865. }
  866. .chat_content_wrap {
  867. flex: 1;
  868. width: 750rpx;
  869. position: relative;
  870. }
  871. .chat-list {
  872. flex: 1;
  873. width: 750rpx;
  874. background-color: #F5F7FA;
  875. }
  876. .page_loading_container {
  877. position: absolute;
  878. top: 0;
  879. left: 0;
  880. right: 0;
  881. bottom: 0;
  882. background-color: #F5F7FA;
  883. align-items: center;
  884. justify-content: center;
  885. z-index: 999;
  886. }
  887. .top_loader_container {
  888. align-items: center;
  889. justify-content: center;
  890. height: 40px;
  891. width: 750rpx;
  892. flex-direction: row;
  893. }
  894. .top_loader_indicator {
  895. height: 20px;
  896. width: 20px;
  897. color: #888;
  898. }
  899. .no_more_text {
  900. font-size: 14px;
  901. color: #999999;
  902. padding: 10px 0;
  903. }
  904. .time_gap_line {
  905. padding: 20rpx 0;
  906. align-items: center;
  907. justify-content: center;
  908. }
  909. .time_gap_text {
  910. font-size: 18px;
  911. color: #A5A5A5;
  912. background-color: transparent;
  913. }
  914. .mutiple_action_container {
  915. flex-direction: row;
  916. border-top-width: 1px;
  917. border-top-style: solid;
  918. border-top-color: #eaebed;
  919. background-color: #f0f2f6;
  920. justify-content: space-evenly;
  921. padding: 12px 24px;
  922. }
  923. .action_item {
  924. align-items: center;
  925. justify-content: center;
  926. }
  927. .action_icon {
  928. width: 48px;
  929. height: 48px;
  930. }
  931. .action_text {
  932. margin-top: 6px;
  933. font-size: 14px;
  934. color: #333;
  935. }
  936. .action_text_red {
  937. color: #ff381f;
  938. }
  939. .loading_overlay {
  940. position: absolute;
  941. top: 0;
  942. left: 0;
  943. right: 0;
  944. bottom: 0;
  945. background-color: transparent;
  946. align-items: center;
  947. justify-content: center;
  948. }
  949. .loading_indicator {
  950. width: 30px;
  951. height: 30px;
  952. color: #999;
  953. }
  954. .page_loading_container {
  955. position: absolute;
  956. top: 0;
  957. left: 0;
  958. right: 0;
  959. bottom: 0;
  960. justify-content: center;
  961. align-items: center;
  962. background-color: #F5F7FA;
  963. }
  964. .page_loading_indicator {
  965. height: 60rpx;
  966. width: 60rpx;
  967. color: #999;
  968. }
  969. .page_loading_text {
  970. margin-top: 20rpx;
  971. font-size: 28rpx;
  972. color: #999;
  973. }
  974. </style>