ChatingList.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. <template>
  2. <scroll-view
  3. :bounces="true"
  4. :scroll-with-animation="false"
  5. @click="click"
  6. @touchstart="onTouchStart"
  7. @touchend="onTouchEnd"
  8. id="scroll_view"
  9. :style="{
  10. height: '100%',
  11. width: '100%',
  12. backgroundImage: `url(${bgUrl})`,
  13. backgroundSize: `100% 100%`,
  14. opacity: listOpacity
  15. }"
  16. :show-scrollbar="true"
  17. @scroll="throttleScroll"
  18. :scroll-y="true"
  19. :scroll-top="scrollTop"
  20. :scroll-into-view="scrollIntoView"
  21. lower-threshold="200"
  22. @scrolltolower="scrolltoupper">
  23. <view id="scroll_wrap">
  24. <!-- 增加一个高度为30px的view,作为视觉顶部的缓冲,防止滚动到顶部时卡死边缘 -->
  25. <!-- <view style="height: 0px; width: 100%; flex-shrink: 0; overflow-anchor: none;"></view> -->
  26. <view v-for="(item, index) in storeHistoryMessageList" :key="item.clientMsgID" class="msgItem" :style="{ zIndex: activeMenuMsgID === item.clientMsgID ? 100 : 0, position: 'relative' }">
  27. <u-loadmore v-if="index==0" v-show="showLoading" :font-size="16" nomoreText="" :status="loadMoreStatus" />
  28. <view v-if="getTimeLine(item,index)" :class="bgUrl==''?'time_gap_line':'time_gap_line color'">
  29. {{ getTimeLine(item, index)}}
  30. </view>
  31. <message-item-render
  32. :source="item"
  33. :senderFaceUrl="item.senderFaceUrl"
  34. :senderNickname="item.senderNickname"
  35. :isSender="item.sendID === storeCurrentUserID"
  36. :mutipleCheckVisible="mutipleCheckVisible"
  37. :menuOutsideFlag="menuOutsideFlag"
  38. @messageItemRender="messageItemRender"
  39. @messageItemReady="messageItemReady"
  40. @closeMenu="closeMenu"
  41. @menuShow="activeMenuMsgID = $event"
  42. @menuHide="activeMenuMsgID = ''"/>
  43. <view v-if="sendFailedDesc(item)" class="time_gap_line send_failed_tip">
  44. {{ sendFailedDesc(item) }}
  45. </view>
  46. </view>
  47. <!-- <view style="visibility: hidden; height: 12px" id="auchormessage_bottom_item"></view> -->
  48. <view id="last-item" key="lastMsgItem" style="height:10px;" class="msgItem" ></view>
  49. </view>
  50. <transition name="fade">
  51. <view click="scrollToBottom(false)" v-show="getNewMesageCount && !needScoll" class="new_message_flag fade">
  52. <image style="height: 10px; width: 11px" src="../../../../static/images/common_db_arrow.png"/>
  53. <text>{{ `${getNewMesageCount}条新消息` }}</text>
  54. </view>
  55. </transition>
  56. </scroll-view>
  57. </template>
  58. <script>
  59. import { mapGetters, mapActions } from 'vuex';
  60. import dayjs from 'dayjs';
  61. import { SendMessageFailedType } from '../../../../constant';
  62. import MessageItemRender from './MessageItem/index.vue';
  63. import { MessageStatus } from 'openim-uniapp-polyfill';
  64. export default {
  65. name: '',
  66. components: {
  67. MessageItemRender
  68. },
  69. props: {
  70. menuOutsideFlag: Number,
  71. mutipleCheckVisible: Boolean,
  72. keyboardHeight: {
  73. type: Number,
  74. default: 0
  75. }
  76. },
  77. watch: {
  78. keyboardHeight(val) {
  79. if (val > 0) {
  80. this.scrollToBottom();
  81. }
  82. },
  83. storeCurrentConversation: {
  84. handler(val) {
  85. if (val && val.conversationID && this.initFlag) {
  86. //console.log("qxj watch storeCurrentConversation trigger load");
  87. //this.loadMessageList();
  88. }
  89. },
  90. deep: true
  91. }
  92. },
  93. data() {
  94. return {
  95. scrollViewHeight: 0, // 滚动容器高度
  96. scrollIntoView: '',
  97. scrollWithAnimation: false,
  98. scrollTop: 0,
  99. old: {
  100. scrollTop: 0
  101. },
  102. initFlag: true,
  103. isOverflow: false,
  104. needScoll: true,
  105. withAnimation: false,
  106. messageLoadState: {
  107. loading: false
  108. },
  109. bgUrl: '',
  110. bgHeight: '',
  111. showLoading:false,
  112. isListReady: false,
  113. scrollTimeout: null,
  114. isTouching: false,
  115. lastScrollTop: 0,
  116. newMsgCount: 0,
  117. currentLastMsgId: '',
  118. activeMenuMsgID: ''
  119. };
  120. },
  121. computed: {
  122. ...mapGetters(['storeCurrentConversation', 'storeHistoryMessageList', 'storeHasMoreMessage', 'storeCurrentUserID', 'storeSelfInfo']),
  123. loadMoreStatus() {
  124. if (!this.storeHasMoreMessage) {
  125. return 'nomore';
  126. }
  127. return this.messageLoadState.loading ? 'loading' : 'loadmore';
  128. },
  129. getNewMesageCount() {
  130. if (this.storeHistoryMessageList.length > 0) {
  131. const lastMessage = this.storeHistoryMessageList[this.storeHistoryMessageList.length - 1];
  132. if (lastMessage && lastMessage.clientMsgID !== this.currentLastMsgId) {
  133. this.currentLastMsgId = lastMessage.clientMsgID;
  134. // 收到新消息,如果不在底部,增加未读计数
  135. if (this.needScoll) {
  136. this.newMsgCount++;
  137. } else {
  138. // 在底部,自动滚动到底部
  139. this.scrollToBottom();
  140. }
  141. }
  142. }
  143. return this.newMsgCount;
  144. },
  145. reversedMessageList() {
  146. return [...this.storeHistoryMessageList].reverse();
  147. },
  148. listOpacity() {
  149. return this.isListReady ? 1 : 0;
  150. },
  151. sendFailedDesc() {
  152. return (message) => {
  153. if (message.status === MessageStatus.Failed && message.errCode === SendMessageFailedType.Blacked) {
  154. return '消息已发出,但被对方拒收了';
  155. }
  156. if (message.status === MessageStatus.Failed && message.errCode === SendMessageFailedType.NotFriend) {
  157. return '对方开启了好友验证,你还不是他(她)好友。请先发送好友验证,对方验证通过后,才能聊天。';
  158. }
  159. return '';
  160. };
  161. },
  162. getTimeLine() {
  163. return (message, index) => {
  164. const sendTime = message.sendTime;
  165. const preMessage=this.storeHistoryMessageList[index-1];
  166. const preSendTime = preMessage?.sendTime;
  167. if(index==0){
  168. return this.formatTime(sendTime);
  169. }
  170. if(index<=this.storeHistoryMessageList.length-1){
  171. // 只在时间间隔大于30秒时才显示时间线
  172. if (preSendTime && sendTime - preSendTime > 30000) {
  173. return this.formatTime(sendTime);
  174. }
  175. }
  176. return null;
  177. };
  178. }
  179. },
  180. beforeMount() {
  181. this.updateBgUrl();
  182. },
  183. mounted() {
  184. this.loadMessageList();
  185. this.$nextTick(() => {
  186. this.checkInitHeight();
  187. });
  188. },
  189. methods: {
  190. formatTime(sendTime) {
  191. const now = dayjs();
  192. const messageDate = dayjs(sendTime);
  193. // 检查是否在今天
  194. if (messageDate.isSame(now, 'day')) {
  195. // 检查是否在几秒内
  196. if (now.diff(messageDate, 'second') < 60) {
  197. return '刚刚';
  198. }
  199. // 今天且大于一分钟,显示小时:分钟
  200. return messageDate.format('HH:mm');
  201. }
  202. // 检查是否是昨天
  203. if (messageDate.isSame(now.subtract(1, 'day'), 'day')) {
  204. return '昨天 ' + messageDate.format('HH:mm');
  205. }
  206. // 检查是否在今年
  207. if (messageDate.isSame(now, 'year')) {
  208. // 今年但不是今天或昨天,显示月日 小时:分钟
  209. return messageDate.format('MM-DD HH:mm');
  210. }
  211. // 今年以前,显示年月日 小时:分钟
  212. return messageDate.format('YYYY-MM-DD HH:mm');
  213. },
  214. ...mapActions('message', ['getHistoryMesageList']),
  215. messageItemRender(clientMsgID) {
  216. if (this.initFlag && clientMsgID === this.storeHistoryMessageList[this.storeHistoryMessageList.length - 1].clientMsgID){
  217. //this.initFlag = false;
  218. //setTimeout(() => this.scrollToBottom(true), 0);
  219. // setTimeout(() => this.scrollToAnchor(`auchor${clientMsgID}`, false, true), 200)
  220. // this.checkInitHeight();
  221. // this.scrollToAnchor('message_bottom_item',true)
  222. }
  223. },
  224. async loadMessageList(isLoadMore = false) {
  225. this.showLoading=isLoadMore;
  226. this.messageLoadState.loading = true;
  227. // 记录加载前的第一条消息ID,作为锚点
  228. let anchorMsgID = null;
  229. if (isLoadMore) {
  230. this.withAnimation = false; // 禁用动画
  231. anchorMsgID = this.storeHistoryMessageList[0]?.clientMsgID;
  232. }
  233. try {
  234. if (!this.storeCurrentConversation || !this.storeCurrentConversation.conversationID) {
  235. this.messageLoadState.loading = false;
  236. return;
  237. }
  238. const options = {
  239. conversationID: this.storeCurrentConversation.conversationID,
  240. count: 20,
  241. startClientMsgID: this.storeHistoryMessageList[0]?.clientMsgID ?? '',
  242. viewType: 0
  243. };
  244. const { emptyFlag } = await this.getHistoryMesageList(options);
  245. if(emptyFlag){
  246. this.$emit('initSuccess');
  247. }
  248. } catch (e) {
  249. console.log(e);
  250. }
  251. this.$nextTick(async () => {
  252. if (isLoadMore && anchorMsgID) {
  253. // 锚点定位法:找到之前的第一条消息现在的位置
  254. // 注意:scroll-view 的 scroll-into-view 属性在 prepend 数据时可能更稳定
  255. // this.scrollIntoView = '';
  256. // this.$nextTick(() => {
  257. // this.scrollIntoView = `auchor${anchorMsgID}`;
  258. // });
  259. } else if (!isLoadMore) {
  260. this.scrollToBottom(true);
  261. }
  262. this.messageLoadState.loading = false;
  263. });
  264. },
  265. click(e) {
  266. this.$emit('click', e);
  267. },
  268. getReversedTimeLine(item, nextItem) {
  269. if (!nextItem) return this.getTimeLine(item, null);
  270. return this.getTimeLine(item, nextItem);
  271. },
  272. onScroll(event) {
  273. //console.log("qxj onScroll event",event);
  274. // const { scrollHeight, scrollTop } = event.target;
  275. // this.old.scrollTop = scrollTop;
  276. // this.needScoll = scrollHeight - scrollTop < uni.getWindowInfo().windowHeight * 1.2;
  277. },
  278. throttleScroll(event) {
  279. //console.log("qxj throttleScroll event",event);
  280. this.$emit('closeMenu', event);
  281. if(this.isListReady){
  282. const { scrollTop, scrollHeight } = event.detail;
  283. this.lastScrollTop = scrollTop;
  284. this.lastScrollHeight = scrollHeight;
  285. if (this.isTouching) return;
  286. this.checkScroll();
  287. }
  288. },
  289. checkScroll() {
  290. if (this.scrollTimeout) {
  291. clearTimeout(this.scrollTimeout);
  292. }
  293. this.scrollTimeout = setTimeout(() => {
  294. // 修复顶部锁定 (视觉底部/逻辑顶部)
  295. if(this.lastScrollTop < 0.5) {
  296. this.scrollTop = this.lastScrollTop;
  297. this.$nextTick(() => {
  298. this.scrollTop = 0.5;
  299. });
  300. }
  301. // 修复视觉顶部(逻辑底部)卡死问题,同时兼容OPPO越界滚动
  302. if (this.scrollViewHeight > 0 && this.lastScrollHeight > 0) {
  303. const maxScroll = this.lastScrollHeight - this.scrollViewHeight;
  304. // 计算当前滚动位置超出最大值的距离
  305. const diff = this.lastScrollTop - maxScroll;
  306. //console.log("qxj diff",diff);
  307. // 只有当超出距离在较小范围内(例如 20px 以内)时,才认为是“到达边界并可能卡死”,进行微调。
  308. if (maxScroll > 0 && diff >= 0 && diff < 5) {
  309. this.scrollTop = this.lastScrollTop;
  310. this.$nextTick(() => {
  311. this.scrollTop = maxScroll - 2;
  312. });
  313. }
  314. }
  315. }, 100);
  316. },
  317. onTouchStart() {
  318. this.isTouching = true;
  319. if (this.scrollTimeout) {
  320. clearTimeout(this.scrollTimeout);
  321. }
  322. },
  323. onTouchEnd() {
  324. this.isTouching = false;
  325. this.checkScroll();
  326. },
  327. scrolltoupper() {
  328. if (!this.messageLoadState.loading && this.storeHasMoreMessage) {
  329. this.loadMessageList(true);
  330. }
  331. },
  332. scrollToBottom(isInit = false, isRecv = false) {
  333. // if (isRecv && !this.needScoll) {
  334. // return;
  335. // }
  336. // // 首次加载不使用动画,避免视觉上的滑动
  337. // if (!isInit) {
  338. // this.withAnimation = true;
  339. // // 动画结束后重置,防止影响下一次手动调整
  340. // setTimeout(() => (this.withAnimation = false), 250);
  341. // } else {
  342. // this.withAnimation = false;
  343. // }
  344. this.scrollIntoView = '';
  345. this.scrollTop = 1;
  346. this.$nextTick(() => {
  347. // 延迟再次设置,确保渲染完成后生效
  348. setTimeout(() => {
  349. this.scrollTop = 10;
  350. if (isInit) {
  351. this.isListReady = true;
  352. this.$emit('initSuccess');
  353. }
  354. }, 200);
  355. });
  356. },
  357. scrollToAnchor(auchor) {
  358. this.$nextTick(function () {
  359. this.scrollIntoView = auchor;
  360. });
  361. },
  362. messageItemReady(clientMsgID) {
  363. // const last = this.storeHistoryMessageList[this.storeHistoryMessageList.length - 1];
  364. // if (!last) return;
  365. // if (clientMsgID === last.clientMsgID) {
  366. // this.scrollToBottom(true);
  367. // }
  368. },
  369. checkInitHeight() {
  370. this.getEl('#scroll_view').then(({ height }) => {
  371. console.log('height',height)
  372. this.bgHeight = `${height}px`;
  373. this.scrollViewHeight = height;
  374. });
  375. },
  376. getEl(el) {
  377. return new Promise((resolve) => {
  378. const query = uni.createSelectorQuery().in(this);
  379. query.select(el)
  380. .boundingClientRect((data) => {
  381. resolve(data);
  382. }).exec();
  383. });
  384. },
  385. closeMenu() {
  386. this.$emit('closeMenu');
  387. },
  388. updateBgUrl() {
  389. const bgMap = uni.getStorageSync('IMBgMap') || {};
  390. this.bgUrl = bgMap[this.$store.getters.storeCurrentConversation.conversationID] || '';
  391. console.log('this.bgUrl',bgMap,this.bgUrl)
  392. }
  393. }
  394. };
  395. </script>
  396. <style lang="scss">
  397. #scroll_view {
  398. display: flex;
  399. background-repeat: no-repeat;
  400. position: relative;
  401. z-index: 10 !important;
  402. // overflow-anchor: auto !important;
  403. // -webkit-overflow-scrolling: touch;
  404. //上下翻转
  405. transform: scale(1, -1);
  406. /* 强制显示滚动条 */
  407. /deep/ ::-webkit-scrollbar {
  408. display: block !important;
  409. width: 4px !important;
  410. height: 4px !important;
  411. background: transparent !important;
  412. }
  413. /deep/ ::-webkit-scrollbar-thumb {
  414. background-color: rgba(51, 51, 51, 0.6) !important;
  415. border-radius: 3px !important;
  416. }
  417. /deep/ ::-webkit-scrollbar-track {
  418. background: transparent !important;
  419. }
  420. }
  421. #scroll_wrap{
  422. display: flex;
  423. min-height: 100%;
  424. flex: 1;
  425. flex-direction: column-reverse;
  426. // justify-content: flex-end;
  427. }
  428. .msgItem{
  429. transform: scale(1, -1);
  430. display: flex;
  431. flex-direction: column;
  432. flex-shrink: 0;
  433. width: 100%;
  434. }
  435. .watermark-view {
  436. width: 100%;
  437. height: 100%;
  438. position: fixed;
  439. }
  440. .watermark {
  441. font-size: 16px; /* 水印文字大小 */
  442. color: #f0f2f6; /* 水印文字颜色,使用透明度控制可见度 */
  443. position: absolute; /* 水印相对定位 */
  444. transform: rotate(-45deg);
  445. pointer-events: none; /* 防止水印文字干扰交互 */
  446. }
  447. .uni-scroll-view {
  448. position: relative;
  449. }
  450. .new_message_flag {
  451. position: sticky;
  452. background: #ffffff;
  453. box-shadow: 0px 3px 8px 0px rgba(0, 0, 0, 0.1);
  454. border-radius: 14px;
  455. padding: 4px 8px;
  456. display: flex;
  457. justify-content: center;
  458. align-items: center;
  459. bottom: 12px;
  460. left: 50%;
  461. transform: translateX(-50%);
  462. width: fit-content;
  463. font-size: 24rpx;
  464. color: #006aff;
  465. }
  466. .time_gap_line {
  467. position: relative;
  468. padding: 10rpx 10vw 0rpx;
  469. text-align: center;
  470. font-size: 13px;
  471. color: #A5A5A5;
  472. &.color{
  473. color:#fff
  474. }
  475. }
  476. .fade-leave,
  477. .fade-enter-to {
  478. opacity: 1;
  479. }
  480. .fade-leave-active,
  481. .fade-enter-active {
  482. transition: all 0.3s;
  483. }
  484. .fade-leave-to,
  485. .fade-enter {
  486. opacity: 0;
  487. }
  488. </style>