yuhongqi 4 часов назад
Родитель
Сommit
9eede3839a
3 измененных файлов с 364 добавлено и 196 удалено
  1. 2 0
      .env.prod-bjzm
  2. 3 1
      src/utils/liveWS.js
  3. 359 195
      src/views/live/liveConsole/LiveConsole.vue

+ 2 - 0
.env.prod-bjzm

@@ -43,3 +43,5 @@ VUE_APP_PROJECT_FROM=bjzm
 VUE_CLI_BABEL_TRANSPILE_MODULES = true
 
 VUE_APP_LIVE_WS_URL = wss://ws.klbycp.com/ws
+# 【V-06】管理端 WS HMAC 固定密钥,需与后端 live.ws.admin-secret 一致
+VUE_APP_LIVE_WS_ADMIN_SECRET = live-ws-admin-hmac-v1

+ 3 - 1
src/utils/liveWS.js

@@ -9,11 +9,13 @@ export class LiveWS {
    * @param {number} reconnectDelay - 连接断开后重连的延迟,单位毫秒
    */
   constructor(url, liveId, userId, checkInterval = 5000, reconnectDelay = 3000) {
+    // 【V-06 修复】HMAC 密钥改为服务端固定 secret(与 live.ws.admin-secret 一致)
     let timestamp = new Date().getTime()
     let userType = 1
+    const adminSecret = process.env.VUE_APP_LIVE_WS_ADMIN_SECRET || 'live-ws-admin-hmac-v1'
     let signature = CryptoJS.HmacSHA256(
       CryptoJS.enc.Utf8.parse('' + liveId + userId + userType + timestamp),
-      CryptoJS.enc.Utf8.parse(timestamp)).toString(CryptoJS.enc.Hex)
+      CryptoJS.enc.Utf8.parse(adminSecret)).toString(CryptoJS.enc.Hex)
     this.url = url + `?liveId=${liveId}&userId=${userId}&userType=${userType}&timestamp=${timestamp}&signature=${signature}`;
     console.log(this.url)
     this.liveId = liveId;

+ 359 - 195
src/views/live/liveConsole/LiveConsole.vue

@@ -120,6 +120,9 @@
                 <el-row style="margin-left: 10px">
                   <el-col><div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div></el-col>
                   <el-col :span="24" style="max-width: 200px;">
+                    <div v-if="m.replyUserName || m.replyContent" style="font-size: 12px; color: #999; margin-bottom: 4px; word-break: break-all;">
+                      回复 {{ m.replyUserName || '用户' }}:{{ m.replyContent }}
+                    </div>
                     <div style="white-space: normal; word-wrap: break-word;background-color: #f0f2f5; padding: 8px; border-radius: 5px;font-size: 14px;width: 100%;">
                       {{ m.msg }}
                     </div>
@@ -139,6 +142,9 @@
               <div style="display: flex;justify-content: flex-end">
                 <div style="display: flex;justify-content: flex-end;flex-direction: column;max-width: 200px;align-items: flex-end">
                   <div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div>
+                  <div v-if="m.replyUserName || m.replyContent" style="font-size: 12px; color: #999; margin-bottom: 4px; max-width: 200px; word-break: break-all; text-align: right;">
+                    回复 {{ m.replyUserName || '用户' }}:{{ m.replyContent }}
+                  </div>
                   <div style="white-space: normal; word-wrap: break-word;width: 100%; background-color: #e6f7ff; padding: 8px; border-radius: 5px;font-size: 14px;">{{ m.msg }}</div>
                 </div>
                 <el-avatar :src="m.avatar" style="margin-left: 10px; margin-right: 10px;"/>
@@ -148,17 +154,17 @@
           <!-- 底部留白 -->
           <div style="height: 20px;"></div>
           </el-scrollbar>
-          <!-- 加载最新消息按钮 -->
+          <!-- 暂停后:回到最新并恢复实时滚动 -->
           <el-button
             v-if="showLoadLatestBtn"
             class="load-latest-btn"
             type="primary"
             size="small"
             @click.stop="loadLatestMessages"
-            :disabled="isLoadingLatest"
-            :loading="isLoadingLatest"
-            icon="el-icon-refresh">
-            加载最新消息
+            :disabled="isLoadingHistory"
+            :loading="isLoadingHistory"
+            icon="el-icon-bottom">
+            回到最新
           </el-button>
         </div>
         <!--        <div class="message-list">-->
@@ -408,12 +414,20 @@ export default {
         msg: '',
         duration: 5
       },
-      // 消息滚动控制
-      isAutoScrollEnabled: true, // 是否启用自动滚动
-      autoScrollTimer: null, // 自动滚动定时器
-      showLoadLatestBtn: false, // 是否显示加载最新消息按钮
-      isLoadingLatest: false, // 是否正在加载最新消息
-      scrollDebounceTimer: null, // 滚动防抖定时器
+      // 消息滚动控制:窗口固定 30 条,暂停后可上下游标翻页
+      MSG_WINDOW_SIZE: 30,
+      isAutoScrollEnabled: true,
+      autoScrollTimer: null,
+      showLoadLatestBtn: false,
+      isLoadingHistory: false,
+      hasMoreOlder: true,
+      hasMoreNewer: false,
+      // 边缘触达后需先离开再触达,才允许再次加载(防止贴边连打接口)
+      olderEdgeArmed: true,
+      newerEdgeArmed: true,
+      // 回到最新过程中忽略滚动暂停,避免清空列表触发 scroll 又把自动跟随关掉
+      suppressScrollPause: false,
+      scrollDebounceTimer: null,
       msgParams: {
         pageNum: 1,
         pageSize: 30,
@@ -459,65 +473,192 @@ export default {
     this.initScrollListeners();
   },
   methods: {
-    // 点击消息框
+    // 点击消息框:暂停实时滚动,显示「回到最新」
     handleMessageBoxClick() {
-      // 点击消息框时,停止自动滚动
-      this.isAutoScrollEnabled = false;
-      // 停止自动滚动定时器
+      this.pauseMsgAutoScroll()
+    },
+    pauseMsgAutoScroll() {
+      this.isAutoScrollEnabled = false
       if (this.autoScrollTimer) {
-        clearTimeout(this.autoScrollTimer);
-        this.autoScrollTimer = null;
+        clearTimeout(this.autoScrollTimer)
+        this.autoScrollTimer = null
       }
-      // 检查是否在底部,如果不在底部则显示加载最新消息按钮
-      this.$nextTick(() => {
-        if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
-          const wrap = this.$refs.manageRightRef.wrap;
-          const scrollHeight = wrap.scrollHeight;
-          const clientHeight = wrap.clientHeight;
-          const currentScrollTop = wrap.scrollTop;
-          const maxScrollTop = scrollHeight - clientHeight;
-
-          if (currentScrollTop < maxScrollTop - 50) {
-            this.showLoadLatestBtn = true;
-          }
-        }
-      });
+      this.showLoadLatestBtn = true
     },
-    // 滚动到底部
     scrollToBottom(forceScroll = false) {
-      // 如果自动滚动被禁用且不是强制滚动,则不执行
       if (!this.isAutoScrollEnabled && !forceScroll) {
-        return;
+        return
       }
-
       if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
         this.$nextTick(() => {
-          const wrap = this.$refs.manageRightRef.wrap;
-          if (!wrap) return;
-
-
-          // 强制滚动或启用自动滚动时,直接滚动到底部并隐藏按钮
+          const wrap = this.$refs.manageRightRef.wrap
+          if (!wrap) return
           if (forceScroll || this.isAutoScrollEnabled) {
-            this.showLoadLatestBtn = false;
-            this.$refs.manageRightRef.wrap.scrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight
+            this.showLoadLatestBtn = false
+            wrap.scrollTop = wrap.scrollHeight - wrap.clientHeight
           }
-        });
+        })
+      }
+    },
+    scrollMsgToTop() {
+      if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
+        this.$refs.manageRightRef.wrap.scrollTop = 0
       }
     },
-    // 加载最新消息
+    scrollMsgToBottom() {
+      if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
+        const wrap = this.$refs.manageRightRef.wrap
+        wrap.scrollTop = Math.max(0, wrap.scrollHeight - wrap.clientHeight)
+      }
+    },
+    // 回到最新:重新拉最近 30 条并恢复 socket 跟随滚动
     loadLatestMessages() {
-      // 如果正在加载,直接返回
-      if (this.isLoadingLatest) {
-        return;
+      if (this.isLoadingHistory) {
+        return
+      }
+      this.suppressScrollPause = true
+      this.isLoadingHistory = true
+      this.showLoadLatestBtn = false
+      this.isAutoScrollEnabled = true
+      this.hasMoreOlder = true
+      this.hasMoreNewer = false
+      this.olderEdgeArmed = true
+      this.newerEdgeArmed = true
+      if (this.scrollDebounceTimer) {
+        clearTimeout(this.scrollDebounceTimer)
+        this.scrollDebounceTimer = null
+      }
+      this.msgList = []
+      this.msgParams.pageNum = 1
+      this.msgParams.pageSize = this.MSG_WINDOW_SIZE
+      this.loadMsgList(true)
+    },
+    decorateMsgRows(rows) {
+      if (!rows || !rows.length) {
+        return
+      }
+      rows.forEach(row => {
+        let user = this.alDisplayList.find(u => u.userId === row.userId)
+        row.msgStatus = user ? user.msgStatus : 0
+        if (row.userId != null) {
+          const uid = String(row.userId)
+          if (!Object.prototype.hasOwnProperty.call(this.userSingleVisibleMap, uid)) {
+            this.$set(this.userSingleVisibleMap, uid, Number(row.singleVisible) === 1 ? 1 : 0)
+          } else {
+            this.$set(row, 'singleVisible', Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0)
+          }
+        }
+      })
+      this.alDisplayList.forEach(u => {
+        rows.filter(m => m.userId === u.userId).forEach(m => { m.msgStatus = u.msgStatus })
+      })
+    },
+    getFirstMsgId() {
+      for (let i = 0; i < this.msgList.length; i++) {
+        if (this.msgList[i] && this.msgList[i].msgId != null) {
+          return this.msgList[i].msgId
+        }
+      }
+      return null
+    },
+    getLastMsgId() {
+      for (let i = this.msgList.length - 1; i >= 0; i--) {
+        if (this.msgList[i] && this.msgList[i].msgId != null) {
+          return this.msgList[i].msgId
+        }
+      }
+      return null
+    },
+    // 向上翻:更早的 30 条,窗口裁剪后滚到中间
+    loadOlderMessages() {
+      if (this.isLoadingHistory || this.isAutoScrollEnabled || !this.hasMoreOlder) {
+        return
+      }
+      const beforeMsgId = this.getFirstMsgId()
+      if (beforeMsgId == null) {
+        return
+      }
+      this.isLoadingHistory = true
+      listLiveSingleMsg({
+        liveId: this.liveId,
+        pageNum: 1,
+        pageSize: this.MSG_WINDOW_SIZE,
+        beforeMsgId
+      }).then(response => {
+        const { code, rows } = response
+        if (code !== 200) {
+          return
+        }
+        let list = (rows || []).slice()
+        this.hasMoreOlder = list.length >= this.MSG_WINDOW_SIZE
+        list.reverse()
+        list = list.filter(r => r.msgId != null && !this.msgList.some(m => m.msgId === r.msgId))
+        this.decorateMsgRows(list)
+        if (!list.length) {
+          this.hasMoreOlder = false
+          this.olderEdgeArmed = false
+          this.$nextTick(() => this.scrollMsgToBottom())
+          return
+        }
+        this.msgList = list.concat(this.msgList)
+        if (this.msgList.length > this.MSG_WINDOW_SIZE) {
+          this.msgList = this.msgList.slice(0, this.MSG_WINDOW_SIZE)
+          this.hasMoreNewer = true
+        }
+        // 加载更早后滚到底:需先往下再滚回顶部,才能再次向上查
+        this.$nextTick(() => {
+          this.scrollMsgToBottom()
+          this.olderEdgeArmed = false
+          this.newerEdgeArmed = false
+        })
+      }).finally(() => {
+        this.isLoadingHistory = false
+      })
+    },
+    // 向下翻:更新的 30 条,窗口裁剪后滚到顶部
+    loadNewerMessages() {
+      if (this.isLoadingHistory || this.isAutoScrollEnabled || !this.hasMoreNewer) {
+        return
+      }
+      const afterMsgId = this.getLastMsgId()
+      if (afterMsgId == null) {
+        return
       }
-      this.isLoadingLatest = true;
-      this.showLoadLatestBtn = false;
-      // 恢复自动滚动
-      this.isAutoScrollEnabled = true;
-      // 重新请求最新消息
-      this.resetMsgParams();
-      // loadMsgList 中会自动滚动到底部,因为 isAutoScrollEnabled 已经是 true
-      this.loadMsgList();
+      this.isLoadingHistory = true
+      listLiveSingleMsg({
+        liveId: this.liveId,
+        pageNum: 1,
+        pageSize: this.MSG_WINDOW_SIZE,
+        afterMsgId
+      }).then(response => {
+        const { code, rows } = response
+        if (code !== 200) {
+          return
+        }
+        let list = (rows || []).slice()
+        this.hasMoreNewer = list.length >= this.MSG_WINDOW_SIZE
+        list = list.filter(r => r.msgId != null && !this.msgList.some(m => m.msgId === r.msgId))
+        this.decorateMsgRows(list)
+        if (!list.length) {
+          this.hasMoreNewer = false
+          this.newerEdgeArmed = false
+          this.$nextTick(() => this.scrollMsgToTop())
+          return
+        }
+        this.msgList = this.msgList.concat(list)
+        if (this.msgList.length > this.MSG_WINDOW_SIZE) {
+          this.msgList = this.msgList.slice(this.msgList.length - this.MSG_WINDOW_SIZE)
+          this.hasMoreOlder = true
+        }
+        // 加载更新后滚到顶:需先往上再滚回底部,才能再次向下查
+        this.$nextTick(() => {
+          this.scrollMsgToTop()
+          this.olderEdgeArmed = false
+          this.newerEdgeArmed = false
+        })
+      }).finally(() => {
+        this.isLoadingHistory = false
+      })
     },
     msgRowKey(m, index) {
       if (m && m.msgId != null && m.msgId !== '') {
@@ -576,7 +717,10 @@ export default {
           cmd: 'replyUser',
           msg: value.trim(),
           avatar: this.$store.state.user.user.avatar,
-          nickName: this.$store.state.user.user.nickName
+          nickName: this.$store.state.user.user.nickName,
+          replyMsgId: m.msgId,
+          replyUserName: m.nickName || '',
+          replyContent: m.msg || ''
         }
         this.socket.send(JSON.stringify(msg))
       }).catch(() => {})
@@ -788,21 +932,24 @@ export default {
               this.$set(this.userSingleVisibleMap, uid, Number(message.singleVisible) === 1 ? 1 : 0)
             }
           }
-          if(this.msgList.length > 50){
-            this.msgList.shift()
+          // 暂停浏览历史时不插入实时消息,避免窗口膨胀;点「回到最新」再同步
+          if (!this.isAutoScrollEnabled) {
+            this.showLoadLatestBtn = true
+            return
+          }
+          if (message.msgId != null && this.msgList.some(m => m.msgId === message.msgId)) {
+            return
           }
           this.msgList.push(message)
-          // 如果启用自动滚动,自动滚动到底部
-          if (this.isAutoScrollEnabled) {
-            this.$nextTick(() => {
-              this.autoScrollTimer = setTimeout(() => {
-                this.scrollToBottom();
-              }, 200);
-            });
-          } else {
-            // 自动滚动被禁用时,显示加载最新消息按钮
-            this.showLoadLatestBtn = true;
+          while (this.msgList.length > this.MSG_WINDOW_SIZE) {
+            this.msgList.shift()
+            this.hasMoreOlder = true
           }
+          this.$nextTick(() => {
+            this.autoScrollTimer = setTimeout(() => {
+              this.scrollToBottom()
+            }, 200)
+          })
         } else if (cmd === 'entry' || cmd === 'out') {
           const user = data;
           const online = cmd === 'entry' ? 0 : 1; // 0=在线,1=离线
@@ -1021,21 +1168,51 @@ export default {
       this.resetUserParams()
       this.loadNextPage(this.currentTab)
     },
+    /** 按 Tab 组装列表查询参数,与 liveUserTotals 口径一致 */
+    buildWatchUserQuery(tabName, pageNum) {
+      const queryParams = {
+        liveId: this.liveId,
+        pageNum,
+        pageSize: 20,
+        userName: this.searchKeyword
+      };
+      if (tabName === 'al') {
+        queryParams.all = 1;
+      } else if (tabName === 'online') {
+        queryParams.online = 0;
+        queryParams.msgStatus = 0;
+      } else if (tabName === 'offline') {
+        queryParams.online = 1;
+        queryParams.msgStatus = 0;
+      } else if (tabName === 'silenced') {
+        // 禁言:不传 online,在线/离线禁言都查
+        queryParams.msgStatus = 1;
+      }
+      return queryParams;
+    },
+    /** Tab 人数与列表接口 total 保持一致 */
+    syncUserTotalFromList(tabName, total) {
+      const fieldMap = { al: 'al', online: 'online', offline: 'offline', silenced: 'silenced' };
+      const field = fieldMap[tabName];
+      if (field != null && total != null) {
+        this.$set(this.userTotal, field, total);
+      }
+    },
     handleUserClick(tab){
       const tabName = tab.name;
       this.currentTab = tabName;
       const params = this.pageParams[tabName];
       const displayList = this[`${tabName}DisplayList`];
-      // 首次切换到该Tab或列表为空时初始化
+      // 首次切换到该Tab或列表未满一页时重新拉取,保证人数与接口 total 一致
       if (displayList.length < 20) {
-        // 重置分页参数
+        // 重置分页参数,清空本地缓存列表(避免前端乐观更新与接口不一致)
         params.currentPage = 1;
         params.pageSize = 20;
         params.prevPage = 0;
         params.totalLoaded = 0;
         params.hasMore = true;
         params.hasPrev = false;
-        // 加载第一页
+        this[`${tabName}DisplayList`] = [];
         this.loadNextPage(tabName);
       } else {
         // 非首次切换,恢复滚动位置
@@ -1057,15 +1234,8 @@ export default {
       }
 
       this.scrLoading[tabName].next = true;
-      const queryParams = {
-        liveId: this.liveId,
-        pageNum: params.currentPage,
-        pageSize: 20,
-        online: tabName === 'online' ? 0 : 1,
-        msgStatus: tabName === 'silenced' ? 1 : 0,
-        all: tabName === 'al' ? 1 : 0,
-        userName: this.searchKeyword
-      };
+      const isFirstPage = params.currentPage === 1;
+      const queryParams = this.buildWatchUserQuery(tabName, params.currentPage);
       // 调用接口加载对应状态的分页数据(需后端支持按状态筛选)
       dashBoardWatchUserList(queryParams).then(response => {
         this.scrLoading[tabName].next = false;
@@ -1073,22 +1243,25 @@ export default {
 
         const { rows, total } = response;
         params.total = total; // 记录总数据量
-        // 过滤重复数据(基于userId)
-        const newRows = rows.filter(row =>
+        this.syncUserTotalFromList(tabName, total);
+        // 过滤重复数据(基于 userId)
+        const newRows = (rows || []).filter(row =>
           !displayList.some(u => u.userId === row.userId)
         );
-        displayList.push(...newRows)
-        // 添加新数据并限制最大长度(避免内存占用过大)
-        if (displayList.length >= 40) { // 最大保留100条
-          this[`${tabName}DisplayList`] = displayList.slice(-40);
-          // 记录滚动位置(用于加载后校准)
-          const scrollEl = this.getScrollElement(tabName);
-          // 校准滚动位置(保持视觉连续性)
-          this.$nextTick(() => {
-            if (scrollEl) {
-              scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
-            }
-          });
+        if (isFirstPage) {
+          this[`${tabName}DisplayList`] = newRows;
+        } else {
+          displayList.push(...newRows);
+          // 添加新数据并限制最大长度(避免内存占用过大)
+          if (displayList.length >= 40) {
+            this[`${tabName}DisplayList`] = displayList.slice(-40);
+            const scrollEl = this.getScrollElement(tabName);
+            this.$nextTick(() => {
+              if (scrollEl) {
+                scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
+              }
+            });
+          }
         }
         // 更新分页状态
         params.hasMore = params.currentPage * params.pageSize < total;
@@ -1104,37 +1277,28 @@ export default {
       const params = this.pageParams[tabName];
       const displayList = this[`${tabName}DisplayList`];
       // 边界校验:无上一页/正在加载/当前页<=1
-      console.log(`加载 ${tabName} 上一页`);
-      console.log(!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1)
       if (!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1) {
         return;
       }
       this.scrLoading[tabName].prev = true;
       const targetPage = params.prevPage > 0 ? params.prevPage : params.currentPage - 2;
-      const queryParams = {
-        liveId: this.liveId,
-        pageNum: targetPage,
-        pageSize: 20,
-        online: tabName === 'online' ? 0 : 1,
-        msgStatus: tabName === 'silenced' ? 1 : 0,
-        all: tabName === 'al' ? 1 : 0,
-        userName: this.searchKeyword
-      };
+      const queryParams = this.buildWatchUserQuery(tabName, targetPage);
       dashBoardWatchUserList(queryParams).then(response => {
         this.scrLoading[tabName].prev = false;
         if (response.code !== 200) return;
 
-        const { rows } = response;
-        if (rows.length === 0) {
+        const { rows, total } = response;
+        if (total != null) {
+          params.total = total;
+          this.syncUserTotalFromList(tabName, total);
+        }
+        if (!rows || rows.length === 0) {
           params.hasPrev = false;
           return;
         }
 
         // 记录滚动位置(用于加载后校准)
         const scrollEl = this.getScrollElement(tabName);
-        const scrollTop = scrollEl?.scrollTop || 0;
-        const itemHeight = 80; // 预估行高(根据实际样式调整)
-        const newItemsHeight = rows.length * itemHeight;
 
         // 过滤重复数据并添加到列表头部
         const newRows = rows.filter(row => !displayList.some(u => u.userId === row.userId));
@@ -1225,41 +1389,21 @@ export default {
         }
       })
     },
-    loadMsgList(){
-      // 直播间消息
+    loadMsgList(fromResumeLatest = false){
+      // 初次/回到最新:拉最近 MSG_WINDOW_SIZE 条(desc → reverse 成正序)
       listLiveSingleMsg({
-        liveId:this.liveId,
-        pageNum: this.msgParams.pageNum,
-        pageSize: this.msgParams.pageSize
+        liveId: this.liveId,
+        pageNum: 1,
+        pageSize: this.MSG_WINDOW_SIZE
       }).then(response => {
-        let {code, rows,total} = response;
+        let { code, rows } = response
         if (code === 200) {
-          let totalPage = (total % this.msgParams.pageSize == 0) ? Math.floor(total / this.msgParams.pageSize) : Math.floor(total / this.msgParams.pageSize + 1);
-          rows.forEach(row => {
-            if (!this.msgList.some(m => m.msgId === row.msgId)) {
-              let user = this.alDisplayList.find(u => u.userId === row.userId)
-              if (user) {
-                row.msgStatus = user.msgStatus
-              } else {
-                row.msgStatus = 0
-              }
-              this.msgList.push(row)
-              if (row.userId != null) {
-                const uid = String(row.userId)
-                if (!Object.prototype.hasOwnProperty.call(this.userSingleVisibleMap, uid)) {
-                  this.$set(this.userSingleVisibleMap, uid, Number(row.singleVisible) === 1 ? 1 : 0)
-                } else {
-                  this.$set(row, 'singleVisible', Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0)
-                }
-              }
-            }
-          })
-
-          this.msgList.reverse()
-          // 同步更新消息列表中相同用户的状态
-          this.alDisplayList.forEach(u => {
-            this.msgList.filter(m => m.userId === u.userId).forEach(m => m.msgStatus = u.msgStatus)
-          })
+          let list = (rows || []).slice()
+          this.hasMoreOlder = list.length >= this.MSG_WINDOW_SIZE
+          this.hasMoreNewer = false
+          list.reverse()
+          this.decorateMsgRows(list)
+          this.msgList = list.slice(-this.MSG_WINDOW_SIZE)
           Object.keys(this.userSingleVisibleMap).forEach(uid => {
             const status = Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0
             this.msgList.forEach(m => {
@@ -1268,70 +1412,88 @@ export default {
               }
             })
           })
-
-          // 所有消息加载完成后,根据自动滚动状态决定是否滚动
           this.$nextTick(() => {
-            setTimeout(() => {
-              // 重置加载状态
-              this.isLoadingLatest = false;
-
-              if (this.isAutoScrollEnabled) {
-                // 如果启用自动滚动,强制滚动到底部并隐藏按钮
-                this.scrollToBottom(true);
-              } else {
-                // 如果禁用自动滚动,检查是否在底部,决定是否显示按钮
-                if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
-                  const wrap = this.$refs.manageRightRef.wrap;
-                  const scrollHeight = wrap.scrollHeight;
-                  const clientHeight = wrap.clientHeight;
-                  const currentScrollTop = wrap.scrollTop;
-                  const maxScrollTop = scrollHeight - clientHeight;
-
-                  if (currentScrollTop < maxScrollTop - 50) {
-                    this.showLoadLatestBtn = true;
-                  } else {
-                    this.showLoadLatestBtn = false;
-                  }
-                }
-              }
-            }, 300);
-          });
+            // 回到最新:强制恢复跟随并滚到底,避免被中间的 scroll 事件再次 pause
+            if (fromResumeLatest || this.isAutoScrollEnabled) {
+              this.isAutoScrollEnabled = true
+              this.showLoadLatestBtn = false
+              this.scrollMsgToBottom()
+              this.$nextTick(() => {
+                this.scrollMsgToBottom()
+                this.isLoadingHistory = false
+                this.suppressScrollPause = false
+              })
+            } else {
+              this.isLoadingHistory = false
+              this.suppressScrollPause = false
+              this.showLoadLatestBtn = true
+            }
+          })
+        } else {
+          this.isLoadingHistory = false
+          this.suppressScrollPause = false
         }
+      }).catch(() => {
+        this.isLoadingHistory = false
+        this.suppressScrollPause = false
       })
 
-      // 添加滚动监听
       this.$nextTick(() => {
         if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
-          this.$refs.manageRightRef.wrap.addEventListener("scroll", this.manageRightScroll)
+          this.$refs.manageRightRef.wrap.removeEventListener('scroll', this.manageRightScroll)
+          this.$refs.manageRightRef.wrap.addEventListener('scroll', this.manageRightScroll)
         }
       })
     },
-    // 消息滚动监听(带防抖)
+    // 消息滚动:暂停后顶/底触发游标加载;实时模式滚离底部则暂停
     manageRightScroll() {
-      // 清除之前的防抖定时器
+      if (this.suppressScrollPause || this.isLoadingHistory) {
+        return
+      }
       if (this.scrollDebounceTimer) {
-        clearTimeout(this.scrollDebounceTimer);
+        clearTimeout(this.scrollDebounceTimer)
       }
-
-      // 设置防抖,300ms内只执行一次
       this.scrollDebounceTimer = setTimeout(() => {
-        this.saveChatScrollPosition();
+        if (this.suppressScrollPause || this.isLoadingHistory) {
+          return
+        }
+        this.saveChatScrollPosition()
+        if (!this.$refs.manageRightRef || !this.$refs.manageRightRef.wrap) {
+          return
+        }
+        const wrap = this.$refs.manageRightRef.wrap
+        const scrollHeight = wrap.scrollHeight
+        const clientHeight = wrap.clientHeight
+        const currentScrollTop = wrap.scrollTop
+        const maxScrollTop = Math.max(0, scrollHeight - clientHeight)
+
+        if (this.isAutoScrollEnabled) {
+          // 用户主动上滑离开底部 → 暂停实时跟随
+          if (currentScrollTop < maxScrollTop - 50) {
+            this.pauseMsgAutoScroll()
+          }
+          return
+        }
 
-        // 检查是否滚动到底部
-        if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
-          const wrap = this.$refs.manageRightRef.wrap;
-          const scrollHeight = wrap.scrollHeight;
-          const clientHeight = wrap.clientHeight;
-          const currentScrollTop = wrap.scrollTop;
-          const maxScrollTop = scrollHeight - clientHeight;
-
-          // 如果滚动到底部(距离底部小于50px),隐藏按钮并恢复自动滚动
-          if (currentScrollTop >= maxScrollTop - 50) {
-            this.showLoadLatestBtn = false;
-            this.isAutoScrollEnabled = true;
+        // 暂停态:到顶/到底各加载一次;离开边缘后重新武装,避免贴边连打
+        this.showLoadLatestBtn = true
+        const atTop = currentScrollTop <= 40
+        const atBottom = currentScrollTop >= maxScrollTop - 40
+        if (atTop) {
+          if (this.olderEdgeArmed) {
+            this.loadOlderMessages()
           }
+        } else {
+          this.olderEdgeArmed = true
         }
-      }, 300);
+        if (atBottom) {
+          if (this.newerEdgeArmed) {
+            this.loadNewerMessages()
+          }
+        } else {
+          this.newerEdgeArmed = true
+        }
+      }, 200)
     },
     loadUserTotals() {
       if (!this.liveId) return;
@@ -1418,14 +1580,16 @@ export default {
         }
     },
     resetMsgParams() {
-      // 消息参数保留
-      this.msgList = [];
+      this.msgList = []
+      this.hasMoreOlder = true
+      this.hasMoreNewer = false
+      this.olderEdgeArmed = true
+      this.newerEdgeArmed = true
       this.msgParams = {
         pageNum: 1,
-        pageSize: 30,
+        pageSize: this.MSG_WINDOW_SIZE,
         liveId: this.liveId
-      };
-      // 重置时不改变按钮状态,由后续的滚动逻辑决定
+      }
       this.taskParams = {
         currentPage: 1,
         pageSize: 20,