Ver Fonte

Merge remote-tracking branch 'origin/master_fhhx_20250718' into master_fhhx_20250718

xdd há 1 dia atrás
pai
commit
8c8b676b86

+ 1 - 1
.env.development

@@ -16,4 +16,4 @@ VUE_APP_VIDEO_LINE_1 = https://fs-1319721001.cos.ap-chongqing.myqcloud.com
 # 线路二地址
 VUE_APP_VIDEO_LINE_2 = https://zkzhobs.ylrztop.com
 
-VUE_APP_LIVE_WS_URL = wss://api.fhhx.runtzh.com/ws
+VUE_APP_LIVE_WS_URL = wss://im.fhhx.runtzh.com/ws

+ 1 - 1
.env.production

@@ -14,4 +14,4 @@ VUE_APP_VIDEO_LINE_1 = https://fs-1319721001.cos.ap-chongqing.myqcloud.com
 # 线路二地址
 VUE_APP_VIDEO_LINE_2 = https://zkzhobs.ylrztop.com
 
-VUE_APP_LIVE_WS_URL = wss://api.fhhx.runtzh.com/ws
+VUE_APP_LIVE_WS_URL = wss://im.fhhx.runtzh.com/ws

+ 1 - 1
src/api/live/liveGoods.js

@@ -63,7 +63,7 @@ export function exportLiveGoods(query) {
 // 直播商品
 export function listStoreProduct(data) {
   return request({
-    url: '/store/storeProduct/list',
+    url: '/live/liveGoods/liveList',
     method: 'get',
     params: data
   })

+ 4 - 4
src/api/live/liveQuestionLive.js

@@ -54,18 +54,18 @@ export function getConfig(id) {
 }
 
 // 新增直播观看奖励设置
-export function addConfig(data) {
+export function addConfig(data,liveId) {
   return request({
-    url: '/live/config',
+    url: '/live/config' + '?liveId=' + liveId,
     method: 'post',
     data: data
   })
 }
 
 // 修改直播观看奖励设置
-export function updateConfig(data) {
+export function updateConfig(data,liveId) {
   return request({
-    url: '/live/config',
+    url: '/live/config' + '?liveId=' + liveId,
     method: 'put',
     data: data
   })

+ 8 - 0
src/api/live/liveWatchUser.js

@@ -8,6 +8,14 @@ export function watchUserList(query) {
     params: query
   })
 }
+// 查询直播间用户列表
+export function getLiveUserTotals(query) {
+  return request({
+    url: '/live/liveWatchUser/liveUserTotals',
+    method: 'get',
+    params: query
+  })
+}
 
 // 直播间用户禁言
 export function changeUserStatus(query) {

+ 24 - 25
src/components/Editor/wang.vue

@@ -1,23 +1,23 @@
-<template>  
+<template>
     <div>
       <div  ref='editor1' class="myedit"></div>
-    </div> 
-</template>  
-  
-<script>  
-  import E from 'wangeditor'  
-  export default {  
-    name: 'editoritem',  
-    data() {  
+    </div>
+</template>
+
+<script>
+  import E from 'wangeditor'
+  export default {
+    name: 'editoritem',
+    data() {
       return {
         index:0,
         uploadUrl:process.env.VUE_APP_BASE_API+"/common/uploadWang",
         editor: null
-      }  
+      }
     },
     created() {
-       
-      
+
+
     },
     beforeDestroy() {
       // 销毁编辑器
@@ -25,7 +25,7 @@
         this.editor.destroy()
         this.editor = null
       }
-      
+
     },
     methods:{
       initEditor(){
@@ -36,7 +36,7 @@
             this.editor.config.uploadImgServer = this.uploadUrl;
 
 
-            
+
              this.editor.config.uploadImgMaxLength = 5
             this.editor.config.uploadImgMaxSize = 2 * 1024 * 1024 // 2M
             this.editor.config.uploadImgAccept = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
@@ -69,7 +69,6 @@
               'redo',
           ]
           this.editor.config.onchange = function (newHtml) {
-            console.log(newHtml)
             that.$emit("on-text-change", newHtml);
           }
           this.editor.config.pasteFilterStyle = false
@@ -79,7 +78,7 @@
                 // 上传图片之前
                 before: function(xhr) {
                     //console.log(xhr)
-                    
+
                 },
                 // 图片上传并返回了结果,图片插入已成功
                 success: function(xhr) {
@@ -112,8 +111,8 @@
                           }
                           console.log(url);
                       }
-                      
-                     
+
+
                       // result 必须是一个 JSON 格式字符串!!!否则报错
                 }
             }
@@ -131,13 +130,13 @@
         else{
           this.editor.txt.html(text);
         }
-        
+
       },
      }
-   
-  }  
-</script>  
-  
+
+  }
+</script>
+
 <style scoped>
 .toolbar {
   border: 1px solid #ccc;
@@ -152,5 +151,5 @@
 .w-e-text-container{
   z-index: 1 !important;
 }
- 
-</style>
+
+</style>

+ 0 - 1
src/components/LiveVideoUpload/index.vue

@@ -218,7 +218,6 @@ export default {
         } else {
           line_1 = `${process.env.VUE_APP_VIDEO_LINE_1}${data.urlPath}`;
         }
-        line_1 = line_1.replace(/\.mp4$/, '.m3u8');
 
         let urlPathWithoutFirstSlash = data.urlPath.substring(1);
         this.$emit("update:fileKey", urlPathWithoutFirstSlash);

+ 51 - 6
src/components/VideoUpload/index.vue

@@ -14,8 +14,8 @@
           :auto-upload="false"
           :key="uploadKey"
         >
-          <el-button slot="trigger" size="small" type="primary" >选取视频</el-button>
-          <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">点击上传</el-button>
+<!--          <el-button slot="trigger" size="small" type="primary" >选取视频</el-button>-->
+<!--          <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">点击上传</el-button>-->
           <!-- 仅当showControl为true时显示视频库选取按钮 -->
           <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="success" @click="openVideoLibrary">视频库选取</el-button>
           <!-- 线路一 -->
@@ -48,7 +48,8 @@
       </div>
     </el-form-item>
     <el-form-item label="视频播放">
-      <video v-if="videoUrl" ref="myvideo" :src="videoUrl" id="video" width="100%" height="300px" controls></video>
+      <video v-if="videoUrl.endsWith('mp4')" ref="myvideo" :src="videoUrl" id="video" width="100%" height="300px" controls></video>
+      <video v-if="videoUrl.endsWith('m3u8')" ref="myM3u8Video" :src="videoUrl" id="meu8Video" width="100%" height="300px" controls type="application/x-mpegURL"></video>
       <div v-if="fileName">视频文件名: {{ fileName }}</div>
       <div v-if="fileKey">文件Key: {{ fileKey }}</div>
       <div v-if="fileSize">文件大小(MB): {{ (fileSize / (1024 * 1024)).toFixed(2) }} MB</div>
@@ -122,8 +123,9 @@
 <script>
 import { uploadObject } from "@/utils/cos.js";
 import Pagination from "@/components/Pagination";
-// import { listVideoResource } from '@/api/course/videoResource';
-import { listLiveVideo, getLiveVideo, delLiveVideo, addLiveVideo, updateLiveVideo, exportLiveVideo } from "@/api/live/liveVideo";
+import { listLiveVideo } from "@/api/live/liveVideo";
+import Hls from 'hls.js';
+import {isEmpty} from "@/components/LemonUI/utils/validate";
 
 export default {
   components: {
@@ -181,6 +183,9 @@ export default {
       default: true,
     }
   },
+  destroyed() {
+    this.hls?.destroy();
+  },
   data() {
     return {
       videoName:'',
@@ -228,8 +233,21 @@ export default {
   },
   mounted() {
     this.reset();
+    if (this.videoUrl.endsWith(".m3u8")) {
+      this.$nextTick(() => {
+        this.initPlayer()
+      })
+    }
   },
   watch: {
+    videoUrl(newVal,oldVal){
+      console.log("触发数据改变")
+      if (this.videoUrl.endsWith(".m3u8")) {
+        this.$nextTick(() => {
+          this.initPlayer()
+        })
+      }
+    },
     uploadType(newType) {
       this.localUploadType = newType;
     },
@@ -320,6 +338,29 @@ export default {
         this.$message.error("线路一上传失败");
       }
     },
+    initPlayer() {
+      this.hls?.destroy();
+      if (Hls.isSupported()) {
+        const videoElement = this.$refs.myM3u8Video
+        if (!videoElement) {
+          console.error('找不到 video 元素')
+          return
+        }
+        this.hls = new Hls();
+        this.hls.attachMedia(videoElement);
+        this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
+          this.hls.loadSource(this.videoUrl);
+          this.hls.on(Hls.Events.STREAM_LOADED, (event, data) => {
+            videoElement.play();
+          });
+        });
+        this.hls.on(Hls.Events.ERROR, (event, data) => {
+          console.error('HLS 错误:', data);
+        });
+      } else {
+        console.error('浏览器不支持 HLS')
+      }
+    },
     //上传华为云Obs
     async uploadVideoToHwObs() {
       try {
@@ -404,7 +445,11 @@ export default {
       this.$emit("update:videoUrl", this.selectedVideo.videoUrl);
       this.$emit("change", this.selectedVideo.videoUrl,this.selectedVideo.lineOne);
 
-
+      if (this.videoUrl.endsWith(".m3u8")) {
+        this.$nextTick(() => {
+          this.initPlayer()
+        })
+      }
       this.libraryOpen = false;
     },
     /** 取消视频选择 */

+ 14 - 0
src/utils/util.js

@@ -32,3 +32,17 @@ export function randomString(len) {
     }
     return pwd
 }
+
+function isEmpty(str) {
+  // 检查是否为 null 或 undefined
+  if (str == null) return true;
+
+  // 检查是否为字符串类型
+  if (typeof str !== 'string') {
+    // 如果不是字符串,尝试转换为字符串再判断
+    return String(str).trim() === '';
+  }
+
+  // 字符串类型直接去除空格后判断
+  return str.trim() === '';
+}

+ 1 - 0
src/views/live/live/index.vue

@@ -200,6 +200,7 @@
             icon="el-icon-edit"
             @click="handleUpdate(scope.row)"
             v-hasPermi="['live:live:edit']"
+            v-if="scope.row.status != 2"
           >修改</el-button>
           <el-button
             size="mini"

+ 2 - 2
src/views/live/liveConfig/goods.vue

@@ -267,7 +267,7 @@ export default {
       queryGoodParams: {
         pageNum: 1,
         pageSize: 10,
-        productName: null,
+        keywords: null,
       },
       goodsLiveList: [],
       goodsLiveTotal: 0,
@@ -444,7 +444,7 @@ export default {
     },
     handleGoodsSearch(){
       this.queryGoodParams.pageNum = 1
-      this.queryGoodParams.productName = this.searchTitle
+      this.queryGoodParams.keywords = this.searchTitle
       this.getStoreProductLists()
     },
     handleGoodsChange(goods) {

+ 3 - 3
src/views/live/liveConfig/liveLotteryConf.vue

@@ -254,11 +254,11 @@
                 >
                 <el-option
                   v-for="product in productOptions"
-                  :key="product.goodsId"
+                  :key="product.productId"
                   :label="product.productName"
-                  :value="product.goodsId"
+                  :value="product.productId"
                 />
-                <span style="float: left">{{ product.goodsId }}</span>
+                <span style="float: left">{{ product.productId }}</span>
                 <span style="margin-left: 30px ;">{{product.productName}}</span>
                 </el-select>
               </el-form-item>

+ 3 - 1
src/views/live/liveConfig/preview.vue

@@ -147,9 +147,11 @@ export default {
   methods: {
     submitForm() {
       this.open = false;
+      var line_1 = this.videoUrl;
+      line_1 = line_1.replace(/\.mp4$/, '.m3u8');
       const doParam = {
         liveId: this.liveId,
-        videoUrl: this.videoUrl,
+        videoUrl: line_1,
         videoType: 3,
         duration: this.form.duration,
         fileSize: this.form.fileSize,

+ 2 - 2
src/views/live/liveConfig/watchReward.vue

@@ -283,13 +283,13 @@ export default {
           if (this.watchRewardForm.id == null) {
             // 调用保存观看奖励接口
             // 实现保存逻辑
-            addConfig(this.watchRewardForm).then(res => {
+            addConfig(this.watchRewardForm,this.liveId).then(res => {
               if (res.code == 200) {
                 this.msgSuccess("修改成功");
               }
             })
           } else {
-            updateConfig(this.watchRewardForm).then(response => {
+            updateConfig(this.watchRewardForm,this.liveId).then(response => {
               this.msgSuccess("修改成功");
             });
           }

+ 395 - 43
src/views/live/liveConsole/index.vue

@@ -185,7 +185,7 @@
       <el-tabs class="live-console-tab-right" v-model="tabLeft.activeName" @tab-click="handleClick" :stretch="true">
         <el-tab-pane :label="onlineLabel" name="online">
           <el-scrollbar ref="manageLeftRef_online" style="height: 800px; width: 100%;">
-            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in onlineUserList">
+            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in onlineDisplayList" :key="u.userId">
               <el-col :span="20">
                 <el-row type="flex" align="middle">
                   <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
@@ -207,7 +207,7 @@
         </el-tab-pane>
         <el-tab-pane :label="offlineLabel" name="offline">
           <el-scrollbar ref="manageLeftRef_offline" style="height: 800px; width: 100%;">
-            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in offlineUserList">
+            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in offlineDisplayList" :key="u.userId">
               <el-col :span="20">
                 <el-row type="flex" align="middle">
                   <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
@@ -229,7 +229,7 @@
         </el-tab-pane>
         <el-tab-pane :label="silencedUserLabel" name="silenced">
           <el-scrollbar ref="manageLeftRef_silenced" style="height: 800px; width: 100%;">
-            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in silencedUserList">
+            <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in silencedDisplayList" :key="u.userId">
               <el-col :span="20">
                 <el-row type="flex" align="middle">
                   <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
@@ -257,7 +257,7 @@
 </template>
 
 <script>
-import { blockUser,changeUserStatus, watchUserList } from '@/api/live/liveWatchUser'
+import { blockUser,changeUserStatus,getLiveUserTotals, watchUserList } from '@/api/live/liveWatchUser'
 import { getLiveVideoByLiveId } from '@/api/live/liveVideo'
 import {getLive, getLivingUrl} from '@/api/live/live'
 import { getLiveOrderTimeGranularity } from '@/api/live/liveOrder'
@@ -321,6 +321,50 @@ export default {
       // ... 其他数据
       chatScrollTop: 0, // 保存聊天滚动位置
       socket: null,
+      userTotal: {
+        online: 0,       // 在线总人数
+        offline: 0,      // 离线总人数
+        silenced: 0      // 禁言总人数
+      },
+      // 各Tab的显示列表(仅存储当前需要展示的数据)
+      onlineDisplayList: [],    // 在线用户显示列表
+      offlineDisplayList: [],   // 离线用户显示列表
+      silencedDisplayList: [],  // 禁言用户显示列表
+      // 各Tab的分页参数
+      pageParams: {
+        online: {
+          currentPage: 1,       // 当前页(下一页加载用)
+          pageSize: 20,       // 当前页(下一页加载用)
+          prevPage: 0,          // 上一页页码(上一页加载用)
+          totalLoaded: 0,       // 已加载总条数
+          total: 0,             // 总数据量
+          hasMore: true,        // 是否有下一页
+          hasPrev: false        // 是否有上一页
+        },
+        offline: {
+          currentPage: 1,
+          pageSize: 20,
+          prevPage: 0,
+          totalLoaded: 0,
+          total: 0,
+          hasMore: true,
+          hasPrev: false
+        },
+        silenced: {
+          currentPage: 1,
+          pageSize: 20,
+          prevPage: 0,
+          totalLoaded: 0,
+          total: 0,
+          hasMore: true,
+          hasPrev: false
+        }
+      },
+      scrLoading: {
+        online: { next: false, prev: false },
+        offline: { next: false, prev: false },
+        silenced: { next: false, prev: false }
+      }
     }
   },
   created() {
@@ -340,32 +384,14 @@ export default {
     companyId() {
       return this.$store.state.user.user.companyId
     },
-    onlineUserList() {
-      return this.userList.filter(u => u.online === 0)
-    },
     onlineLabel() {
-      if (this.onlineUserList.length > 0) {
-        return '在线(' + this.onlineUserList.length + ')'
-      }
-      return '在线'
-    },
-    offlineUserList() {
-      return this.userList.filter(u => u.online === 1)
+      return `在线(${this.userTotal.online})`;
     },
     offlineLabel() {
-      if (this.offlineUserList.length > 0) {
-        return '离线(' + this.offlineUserList.length + ')'
-      }
-      return '离线'
-    },
-    silencedUserList() {
-      return this.userList.filter(u => u.msgStatus === 1)
+      return `离线(${this.userTotal.offline})`;
     },
     silencedUserLabel() {
-      if (this.silencedUserList.length > 0) {
-        return '禁言(' + this.silencedUserList.length + ')'
-      }
-      return '禁言'
+      return `禁言(${this.userTotal.silenced})`;
     }
   },
   mounted() {
@@ -380,6 +406,24 @@ export default {
         this.$refs.manageRightRef.wrap.addEventListener('scroll', this.saveChatScrollPosition);
       }
     });
+    this.initScrollListeners();
+  },
+  beforeDestroy() {
+    this.saveTabScrollPositions()
+    // 移除滚动监听(避免内存泄漏)
+    const scrollRefs = {
+      online: this.$refs.manageLeftRef_online,
+      offline: this.$refs.manageLeftRef_offline,
+      silenced: this.$refs.manageLeftRef_silenced
+    };
+    Object.keys(scrollRefs).forEach(tabName => {
+      const scrollEl = scrollRefs[tabName]?.wrap;
+      if (scrollEl) {
+        scrollEl.removeEventListener('scroll', () =>
+          this.handleTabScroll(tabName, scrollEl)
+        );
+      }
+    })
   },
   // 使用 deactivated 和 activated 钩子替代 beforeDestroy 和 destroyed
   deactivated() {
@@ -539,7 +583,6 @@ export default {
 
       const currentTime = new Date().getTime();
       const elapsedTime = currentTime - this.startTime; // 总流逝时间(毫秒)
-      console.log(this.startTime)
 
       if (elapsedTime < 0) {
         elapsedTimeEl.textContent = '00:00:00';
@@ -661,8 +704,158 @@ export default {
       }
     },
     handleClick(tab) {
-      console.log("click",tab.name)
-      console.log("liveId", this.liveId)
+      const tabName = tab.name;
+      const params = this.pageParams[tabName];
+      const displayList = this[`${tabName}DisplayList`];
+      // 首次切换到该Tab或列表为空时初始化
+      if (displayList.length < 20) {
+        // 重置分页参数
+        params.currentPage = 1;
+        params.pageSize = 20;
+        params.prevPage = 0;
+        params.totalLoaded = 0;
+        params.hasMore = true;
+        params.hasPrev = false;
+        // 加载第一页
+        this.loadNextPage(tabName);
+      } else {
+        // 非首次切换,恢复滚动位置
+        this.$nextTick(() => {
+          const scrollEl = this.getScrollElement(tabName);
+          if (scrollEl) {
+            scrollEl.scrollTop = this.tabScrollPositions[tabName] || 0;
+          }
+        });
+      }
+    },
+    saveTabScrollPositions() {
+      this.tabScrollPositions = {
+        online: this.getScrollElement('online')?.scrollTop || 0,
+        offline: this.getScrollElement('offline')?.scrollTop || 0,
+        silenced: this.getScrollElement('silenced')?.scrollTop || 0
+      };
+    },
+    // 加载指定Tab的用户列表(核心加载逻辑)
+    loadNextPage(tabName) {
+      const params = this.pageParams[tabName];
+      const displayList = this[`${tabName}DisplayList`];
+      console.log(`加载 ${tabName} 用户列表`)
+      console.log(!params.hasMore || this.scrLoading[tabName].next)
+      console.log(params.currentPage)
+      // 若没有更多数据或正在加载,直接返回
+      if (!params.hasMore || this.scrLoading[tabName].next) {
+        return;
+      }
+
+      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
+      };
+      // 调用接口加载对应状态的分页数据(需后端支持按状态筛选)
+      watchUserList(queryParams).then(response => {
+        this.scrLoading[tabName].next = false;
+        if (response.code !== 200) return;
+
+        const { rows, total } = response;
+        params.total = 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;
+            }
+          });
+        }
+        // 更新分页状态
+        params.hasMore = params.currentPage * params.pageSize < total;
+        params.currentPage += 1;
+        params.hasPrev = params.currentPage > 2; // 当前页>2时一定有上一页
+        params.prevPage = params.currentPage - 2;
+      }).catch(() => {
+        this.scrLoading[tabName].next = false;
+      });
+    },
+    // 新增:加载上一页(向上滚动时)
+    loadPrevPage(tabName) {
+      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
+      };
+      watchUserList(queryParams).then(response => {
+        this.scrLoading[tabName].prev = false;
+        if (response.code !== 200) return;
+
+        const { rows } = response;
+        if (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));
+        this[`${tabName}DisplayList`] = [...newRows, ...displayList];
+        params.totalLoaded += newRows.length;
+
+        // 限制最大长度
+        if (this[`${tabName}DisplayList`].length > 40) {
+          this[`${tabName}DisplayList`] = this[`${tabName}DisplayList`].slice(0, 40);
+        }
+
+        // 更新分页状态
+        params.prevPage = targetPage - 1;
+        params.hasPrev = targetPage > 1; // 上一页页码>1时还有更多上一页
+        params.currentPage = params.currentPage - 1;
+        if(params.currentPage * 20 < params.total) params.hasMore = true;
+        // 校准滚动位置(保持视觉连续性)
+        this.$nextTick(() => {
+          if (scrollEl) {
+            scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
+          }
+        });
+      }).catch(() => {
+        this.scrLoading[tabName].prev = false;
+      });
+    },
+    // 辅助:获取Tab对应的滚动容器
+    getScrollElement(tabName) {
+      const scrollRefs = {
+        online: this.$refs.manageLeftRef_online,
+        offline: this.$refs.manageLeftRef_offline,
+        silenced: this.$refs.manageLeftRef_silenced
+      };
+      return scrollRefs[tabName]?.wrap;
     },
     getLiveVideo() {
       getLiveVideoByLiveId(this.liveId).then(res => {
@@ -671,22 +864,62 @@ export default {
     },
     getList() {
       this.resetParams()
-      this.loadUserList()
+      // this.loadUserList()
+      this.loadUserTotals(); // 先加载总人数
+      // this.handleClick('online')
+      this.handleClick({name:'online'})
       this.loadMsgList()
     },
+    loadUserTotals() {
+      if (!this.liveId) return;
+      // 假设后端提供一个接口返回总人数(如果没有,可通过首次加载全量数据后统计)
+      getLiveUserTotals({ liveId: this.liveId }).then(res => {
+        if (res.code === 200) {
+          this.userTotal = res.data; // { online, offline, silenced }
+        }
+      });
+    },
     resetParams() {
-      this.userList= []
-      this.userParams = {
-        pageNum: 1,
-        pageSize: 10,
-        liveId: this.liveId
-      }
-      this.msgList = []
+      // 重置各Tab的显示列表和分页参数
+      this.onlineDisplayList = [];
+      this.offlineDisplayList = [];
+      this.silencedDisplayList = [];
+      this.pageParams = {
+        online: {
+          currentPage: 1,       // 当前页(下一页加载用)
+          pageSize: 20,       // 当前页(下一页加载用)
+          prevPage: 0,          // 上一页页码(上一页加载用)
+          totalLoaded: 0,       // 已加载总条数
+          total: 0,             // 总数据量
+          hasMore: true,        // 是否有下一页
+          hasPrev: false        // 是否有上一页
+        },
+        offline: {
+          currentPage: 1,
+          pageSize: 20,
+          prevPage: 0,
+          totalLoaded: 0,
+          total: 0,
+          hasMore: true,
+          hasPrev: false
+        },
+        silenced: {
+          currentPage: 1,
+          pageSize: 20,
+          prevPage: 0,
+          totalLoaded: 0,
+          total: 0,
+          hasMore: true,
+          hasPrev: false
+        }
+      };
+      // 消息参数保留
+      this.msgList = [];
       this.msgParams = {
         pageNum: 1,
         pageSize: 10,
         liveId: this.liveId
-      }
+      };
     },
     loadUserList() {
       if(this.liveId == null)  return
@@ -804,6 +1037,8 @@ export default {
               user.msgStatus = u.msgStatus;
             }
           });
+          // 4. 关键:重新筛选所有Tab的显示列表,确保状态同步
+          this.refreshUserDisplayLists(u);
 
           let msg = u.msgStatus === 0 ? "已解禁" : "已禁言"
           this.msgSuccess(msg);
@@ -812,6 +1047,49 @@ export default {
         this.msgError("操作失败");
       })
     },
+    // 新增:重新筛选所有Tab的显示列表
+    refreshUserDisplayLists(user) {
+      const { userId, msgStatus: newStatus, online } = user;
+      const oldStatus = newStatus === 1 ? 0 : 1; // 操作前的状态(反向)
+
+      // 1. 禁言操作(newStatus=1):从原在线/离线列表移除,加入禁言列表
+      if (newStatus === 1) {
+        // 从在线/离线列表中移除该用户(根据当前在线状态)
+        if (online === 0) {
+          this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== userId);
+          this.userTotal.online = Math.max(0, this.userTotal.online - 1);
+        } else {
+          this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== userId);
+          this.userTotal.offline = Math.max(0, this.userTotal.offline - 1);
+        }
+        this.userTotal.silenced = this.userTotal.silenced + 1;
+        // 添加到禁言列表(去重+限制长度)
+        const silencedList = this.silencedDisplayList.filter(u => u.userId !== userId);
+        silencedList.push(user);
+        this.silencedDisplayList = silencedList.slice(-40);
+      }
+
+      // 2. 解禁操作(newStatus=0):从禁言列表移除,回到原在线/离线列表
+      else {
+        // 从禁言列表移除
+        this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== userId);
+        this.userTotal.silenced = Math.max(0, this.userTotal.silenced - 1);
+        // 回到对应的在线/离线列表(根据当前在线状态)
+        if (online === 0) {
+          // 加入在线列表(去重+限制长度)
+          const onlineList = this.onlineDisplayList.filter(u => u.userId !== userId);
+          onlineList.push(user);
+          this.onlineDisplayList = onlineList.slice(-40);
+          this.userTotal.online = this.userTotal.online + 1;
+        } else {
+          // 加入离线列表(去重+限制长度)
+          const offlineList = this.offlineDisplayList.filter(u => u.userId !== userId);
+          offlineList.push(user);
+          this.offlineDisplayList = offlineList.slice(-40);
+          this.userTotal.offline = this.userTotal.offline + 1;
+        }
+      }
+    },
     connectWebSocket() {
       this.$store.dispatch('initLiveWs', {
         liveWsUrl: this.liveWsUrl,
@@ -835,6 +1113,9 @@ export default {
             message.msgStatus = 0
           }
           delete message.params
+          if(this.msgList.length > 50){
+            this.msgList.shift()
+          }
           this.msgList.push(message)
           // 移动到底部
           this.$nextTick(() => {
@@ -844,12 +1125,48 @@ export default {
           })
         }
         else if (cmd === 'entry' || cmd === 'out') {
-          this.loadUserList()
-          let user = data
-          if(this.userList.length > 0){
-            this.userList = this.userList.filter(u => u.userId !== user.userId)
+          const user = data;
+          const online = cmd === 'entry' ? 0 : 1; // 0=在线,1=离线
+          const info = {
+            online:online,
+            msgStatus: user.msgStatus || 0,
+            nickName: user.nickName || '',
+            userType: user.userType || 0,
+            userId: user.userId || '',
+          };
+
+          // 1. 更新总人数(在线/离线互转)
+          if (cmd === 'entry') {
+            this.userTotal.online += 1;
+            this.userTotal.offline = Math.max(0, this.userTotal.offline - 1); // 确保不小于0
+          } else {
+            this.userTotal.offline += 1;
+            this.userTotal.online = Math.max(0, this.userTotal.online - 1); // 确保不小于0
+          }
+          // 2. 强制更新相关列表(无论当前激活哪个Tab)
+          if (cmd === 'entry') {
+            // 用户进入:从离线列表删除,添加到在线列表
+            this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
+            const newOnlineList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
+            newOnlineList.push(info);
+            this.onlineDisplayList = newOnlineList.slice(-40); // 限制最大50条
+          } else {
+            // 用户离开:从在线列表删除,添加到离线列表
+            this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
+            const newOfflineList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
+            newOfflineList.push(info);
+            this.offlineDisplayList = newOfflineList.slice(-40); // 限制最大50条
+          }
+          // 3. 处理禁言列表(如果用户是禁言状态,无论进出都要同步)
+          if (info.msgStatus === 1) {
+            // 禁言用户:从禁言列表删除旧数据,添加新数据
+            const newSilencedList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
+            newSilencedList.push(info);
+            this.silencedDisplayList = newSilencedList.slice(-40);
+          } else {
+            // 非禁言用户:从禁言列表删除(如果存在)
+            this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
           }
-          this.userList.push(user)
         } else if (cmd === 'live_start') {
 
         } else if (cmd === 'live_end') {
@@ -876,7 +1193,42 @@ export default {
       this.socket.send(JSON.stringify(msg))
 
       this.newMsg = '';
-    }
+    },
+    // 初始化滚动监听(在mounted中调用)
+    initScrollListeners() {
+      // 为每个Tab的滚动容器添加监听
+      this.$nextTick(() => {
+        const scrollRefs = {
+          online: this.$refs.manageLeftRef_online,
+          offline: this.$refs.manageLeftRef_offline,
+          silenced: this.$refs.manageLeftRef_silenced
+        };
+
+        Object.keys(scrollRefs).forEach(tabName => {
+          const scrollEl = scrollRefs[tabName]?.wrap;
+          if (scrollEl) {
+            scrollEl.addEventListener('scroll', () =>
+              this.handleTabScroll(tabName, scrollEl)
+            );
+          }
+        });
+      });
+    },
+    handleTabScroll(tabName, scrollEl) {
+      const { scrollTop, scrollHeight, clientHeight } = scrollEl;
+      const bottomThreshold = 50; // 距离底部100px触发下一页
+      const topThreshold = 50;    // 距离顶部100px触发上一页
+
+      // 加载下一页(滚动到底部附近)
+      if (scrollHeight - scrollTop - clientHeight < bottomThreshold) {
+        this.loadNextPage(tabName);
+      }
+
+      // 加载上一页(滚动到顶部附近)
+      if (scrollTop < topThreshold) {
+        this.loadPrevPage(tabName);
+      }
+    },
   },
   destroyed() {
     this.hls?.destroy();

+ 4 - 0
src/views/live/liveVideo/index.vue

@@ -165,6 +165,9 @@ export default {
       this.loading = true;
       listLiveVideo(this.queryParams).then(response => {
         this.liveVideoList = response.rows;
+        this.liveVideoList.forEach(item => {
+          item.videoUrl = item.videoUrl.replace(".m3u8", ".mp4");
+        });
         this.total = response.total;
         this.loading = false;
       });
@@ -240,6 +243,7 @@ export default {
             this.msgError("请上传视频");
             return;
           }
+          this.form.videoUrl = this.form.videoUrl.replace('.mp4', '.m3u8');
           addLiveVideo(this.form).then(response => {
             this.msgSuccess("新增成功");
             this.open = false;