index.vue 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339
  1. <template>
  2. <!-- 直播中控台 start -->
  3. <el-row type="flex" justify="center" class="live-console" :gutter="10" v-loading="loading">>
  4. <!-- 聊天 start -->
  5. <el-col class="live-console-col" :span="6">
  6. <el-tabs class="live-console-tab-left" v-model="tabRight.activeName" @tab-click="handleClick" :stretch="true">
  7. <el-tab-pane label="讨论" name="talk">
  8. <el-scrollbar style="height: 500px; width: 100%;" ref="manageRightRef">
  9. <el-row v-for="m in msgList" >
  10. <el-row v-if="m.userId !== userId" style="margin-top: 5px" type="flex" align="top" >
  11. <el-col :span="3" style="margin-left: 10px"><el-avatar :src="m.avatar"/></el-col>
  12. <el-col :span="15">
  13. <el-row style="margin-left: 10px">
  14. <el-col><div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div></el-col>
  15. <el-col :span="24" style="max-width: 200px;">
  16. <div style="white-space: normal; word-wrap: break-word;background-color: #f0f2f5; padding: 8px; border-radius: 5px;font-size: 14px;width: 100%;">
  17. {{ m.msg }}
  18. </div>
  19. </el-col>
  20. <el-col>
  21. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click="changeUserState(m)">{{ m.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  22. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click="blockUser(m)">拉黑</a>
  23. </el-col>
  24. </el-row>
  25. </el-col>
  26. </el-row>
  27. <el-row v-if="m.userId === userId" style="padding: 8px 0" type="flex" align="top" justify="end">
  28. <div style="display: flex;justify-content: flex-end">
  29. <div style="display: flex;justify-content: flex-end;flex-direction: column;max-width: 200px;align-items: flex-end">
  30. <div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div>
  31. <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>
  32. </div>
  33. <el-avatar :src="m.avatar" style="margin-left: 10px; margin-right: 10px;"/>
  34. </div>
  35. </el-row>
  36. </el-row>
  37. <!-- 底部留白 -->
  38. <div style="height: 20px;"></div>
  39. </el-scrollbar>
  40. <!-- 消息输入区域 -->
  41. <div style="padding: 10px; border-top: 1px solid #ebeef5; background-color: #fff; min-height: 120px;">
  42. <el-input
  43. type="textarea"
  44. v-model="newMsg"
  45. placeholder="请输入消息..."
  46. :rows="8"
  47. @keyup.enter.native="sendMessage"
  48. clearable
  49. resize="none"
  50. style="flex: 1; margin-right: 10px;"
  51. >
  52. </el-input>
  53. <div style="display: flex; justify-content: flex-end; margin-top: 10px;">
  54. <el-button plain @click="sendMessage">发送</el-button>
  55. </div>
  56. </div>
  57. </el-tab-pane>
  58. </el-tabs>
  59. </el-col>
  60. <!-- 聊天 end -->
  61. <!-- 直播/视频 start -->
  62. <el-col class="live-console-col" :span="12">
  63. <div style="background: #000; border-radius: 5px; overflow: hidden; margin: 10px 5px;">
  64. <div style="border-radius: 5px; overflow: hidden;" v-if="!isAudit">
  65. <img :src="require('@/assets/images/videoIsAudit.png')" style="width: 100%; height: 45vh;">
  66. </div>
  67. <div style="border-radius: 5px; overflow: hidden;" v-else-if="status != 2 && status != 4">
  68. <img :src="require('@/assets/images/videoNotStart.png')" style="width: 100%; height: 45vh;">
  69. </div>
  70. <div style="border-radius: 5px; overflow: hidden;" v-else-if="liveType == 1">
  71. <video
  72. controls
  73. ref="livingPlayer"
  74. autoplay
  75. @click.prevent
  76. @contextmenu.prevent
  77. class="custom-video"
  78. width="100%"
  79. style="display: block; background: #000; height: 45vh;"
  80. ></video>
  81. <!-- 时间显示(可选) -->
  82. <div ref="liveElapsedTime" class="time-display">
  83. 已播放:<span id="liveElapsedTime">00:00:00</span>
  84. </div>
  85. </div>
  86. <div style="border-radius: 5px; overflow: hidden;" v-else-if="liveType == 2">
  87. <video
  88. controls
  89. ref="videoPlayer"
  90. loop
  91. autoplay
  92. width="100%"
  93. muted
  94. playsinline
  95. @click.prevent
  96. @contextmenu.prevent
  97. class="custom-video"
  98. style="display: block; background: #000; height: 40vh;"
  99. >
  100. <source :src="videoUrl" type="application/x-mpegURL">
  101. </video>
  102. <!-- 自定义进度条容器 -->
  103. <div ref="progressBar" class="progress-container">
  104. <div id="progressBar" class="progress-bar"></div>
  105. </div>
  106. <!-- 时间显示(可选) -->
  107. <div ref="elapsedTime" class="time-display">
  108. 已播放:<span id="elapsedTime">00:00:00</span>
  109. </div>
  110. </div>
  111. <div style="border-radius: 5px; overflow: hidden;" v-else-if="liveType == 3">
  112. <video
  113. controls
  114. ref="liveReplay"
  115. loop
  116. autoplay
  117. width="100%"
  118. playsinline
  119. style="display: block; background: #000; height: 40vh;"
  120. >
  121. <source :src="videoUrl" type="application/x-mpegURL">
  122. </video>
  123. </div>
  124. <div style="border-radius: 5px; overflow: hidden;" v-else>
  125. <img :src="require('@/assets/images/videoNotStart.png')" style="width: 100%; height: 45vh;">
  126. </div>
  127. </div>
  128. <!-- 底部导航栏 -->
  129. <div style="display: flex; justify-content: space-around; padding: 15px 0; background: #fff; border-top: 1px solid #f0f0f0;">
  130. <div style="display: flex; flex-direction: column; align-items: center; cursor: pointer;" @click="handleClickRed">
  131. <i class="el-icon-money" style="font-size: 20px;"></i>
  132. <span style="font-size: 12px; margin-top: 4px;">红包配置</span>
  133. </div>
  134. <div style="display: flex; flex-direction: column; align-items: center; cursor: pointer;" @click="handleClickLottery">
  135. <i class="el-icon-present" style="font-size: 20px;"></i>
  136. <span style="font-size: 12px; margin-top: 4px;">抽奖配置</span>
  137. </div>
  138. <div style="display: flex; flex-direction: column; align-items: center; cursor: pointer;" @click="handleClickGoods">
  139. <i class="el-icon-goods" style="font-size: 20px;"></i>
  140. <span style="font-size: 12px; margin-top: 4px;">商品</span>
  141. </div>
  142. <div style="display: flex; flex-direction: column; align-items: center; cursor: pointer;" @click="handleClickOrder">
  143. <i class="el-icon-goods" style="font-size: 20px;"></i>
  144. <span style="font-size: 12px; margin-top: 4px;">直播订单</span>
  145. </div>
  146. <div style="display: flex; flex-direction: column; align-items: center; cursor: pointer;" @click="handleClickCoupon">
  147. <i class="el-icon-goods" style="font-size: 20px;"></i>
  148. <span style="font-size: 12px; margin-top: 4px;">直播优惠券</span>
  149. </div>
  150. </div>
  151. <el-radio-group v-model="tableRadio" >
  152. <el-radio-button label="订单数">订单数</el-radio-button>
  153. </el-radio-group>
  154. <div style="position: relative;width: 100%; height: 300px;">
  155. <div ref="chartContainer" style="width: 100%; height: 100%;"></div>
  156. <div style="position: absolute; top: 10px; right: 10px; background: #fff; padding: 5px; z-index: 1;">
  157. <el-select v-model="searchQuery.timeOptions" placeholder="请选择" style="width: 150px" @change="timeChange">
  158. <el-option
  159. v-for="item in timeOptions"
  160. :key="item.value"
  161. :label="item.label"
  162. :value="item.value">
  163. </el-option>
  164. </el-select>
  165. <el-select v-model="searchQuery.timeGranularity" placeholder="请选择" style="width: 150px" @change="timeGranularityChange">
  166. <el-option
  167. v-for="item in timeGranularity"
  168. :key="item.value"
  169. :label="item.label"
  170. :value="item.value">
  171. </el-option>
  172. </el-select>
  173. <!-- <el-button type="primary" @click="applyFilter">搜索</el-button>-->
  174. </div>
  175. </div>
  176. </el-col>
  177. <!-- 直播/视频 end -->
  178. <!-- 用户列表 start -->
  179. <el-col class="live-console-col" :span="6">
  180. <el-tabs class="live-console-tab-right" v-model="tabLeft.activeName" @tab-click="handleClick" :stretch="true">
  181. <el-tab-pane :label="onlineLabel" name="online">
  182. <el-scrollbar ref="manageLeftRef_online" style="height: 800px; width: 100%;">
  183. <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in onlineDisplayList" :key="u.userId">
  184. <el-col :span="20">
  185. <el-row type="flex" align="middle">
  186. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  187. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  188. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  189. </el-row>
  190. </el-col>
  191. <el-col :span="4" >
  192. <el-popover
  193. width="100"
  194. trigger="click">
  195. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  196. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  197. <i class="el-icon-more" slot="reference"></i>
  198. </el-popover>
  199. </el-col>
  200. </el-row>
  201. </el-scrollbar>
  202. </el-tab-pane>
  203. <el-tab-pane :label="offlineLabel" name="offline">
  204. <el-scrollbar ref="manageLeftRef_offline" style="height: 800px; width: 100%;">
  205. <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in offlineDisplayList" :key="u.userId">
  206. <el-col :span="20">
  207. <el-row type="flex" align="middle">
  208. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  209. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  210. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  211. </el-row>
  212. </el-col>
  213. <el-col :span="4" >
  214. <el-popover
  215. width="100"
  216. trigger="click">
  217. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  218. <i class="el-icon-more" slot="reference"></i>
  219. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  220. </el-popover>
  221. </el-col>
  222. </el-row>
  223. </el-scrollbar>
  224. </el-tab-pane>
  225. <el-tab-pane :label="silencedUserLabel" name="silenced">
  226. <el-scrollbar ref="manageLeftRef_silenced" style="height: 800px; width: 100%;">
  227. <el-row style="margin-top: 10px" type="flex" align="middle" v-for="u in silencedDisplayList" :key="u.userId">
  228. <el-col :span="20">
  229. <el-row type="flex" align="middle">
  230. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  231. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  232. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  233. </el-row>
  234. </el-col>
  235. <el-col :span="4" >
  236. <el-popover
  237. width="100"
  238. trigger="click">
  239. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  240. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  241. <i class="el-icon-more" slot="reference"></i>
  242. </el-popover>
  243. </el-col>
  244. </el-row>
  245. </el-scrollbar>
  246. </el-tab-pane>
  247. </el-tabs>
  248. </el-col>
  249. <!-- 用户列表 end -->
  250. </el-row>
  251. <!-- 直播中控台 end -->
  252. </template>
  253. <script>
  254. import {blockUser, changeUserStatus, getLiveUserTotals, watchUserList} from '@/api/live/liveWatchUser'
  255. import { getLiveVideoByLiveId } from '@/api/live/liveVideo'
  256. import {getLivingUrl, getLive, delLive} from '@/api/live/live'
  257. import { getLiveOrderTimeGranularity } from '@/api/live/liveOrder'
  258. import { listLiveMsg } from '@/api/live/liveMsg'
  259. import Hls from 'hls.js';
  260. import {onBeforeUnmount} from 'vue';
  261. import LiveLotteryConf from '@/views/live/liveConfig/liveLotteryConf.vue'
  262. import LiveRedConf from '@/views/live/liveConfig/liveRedConf.vue'
  263. import LiveGoods from '@/views/live/liveConfig/goods.vue'
  264. import LiveOrder from '@/views/live/liveOrder/index.vue'
  265. import LiveCoupon from '@/views/live/liveConfig/liveCoupon.vue'
  266. import echarts from 'echarts'
  267. export default {
  268. name: "LiveConsole",
  269. components: { LiveLotteryConf,LiveRedConf,LiveGoods },
  270. data() {
  271. return {
  272. loading: true,
  273. tabLeft: {
  274. activeName: "online",
  275. },
  276. tabRight: {
  277. activeName: "talk",
  278. },
  279. livingUrl: "",
  280. videoUrl: "",
  281. status: 0,
  282. loadMsgMaxPage: 2,
  283. his: null,
  284. liveVideo: {},
  285. socket: null,
  286. liveWsUrl: process.env.VUE_APP_LIVE_WS_URL + '/app/webSocket',
  287. userParams:{
  288. pageNum: 1,
  289. pageSize: 10,
  290. liveId: null
  291. },
  292. msgParams: {
  293. pageNum: 1,
  294. pageSize: 10,
  295. liveId: null
  296. },
  297. userList: [],
  298. msgList: [],
  299. newMsg: '',
  300. isAudit: false,
  301. myChart: null, // 用于存储 ECharts 实例
  302. liveType: 1,
  303. tableRadio: '订单数',
  304. // ... 其他数据 ...
  305. searchQuery: {timeOptions:'2',timeGranularity:'10',liveId: null}, // 搜索查询条件
  306. timeOptions: [
  307. {value:'2',label:'最近2小时',key:'2'},
  308. {value:'4',label:'最近4小时',key:'4'},
  309. {value:'all',label:'全场',key:'all'},
  310. ],
  311. timeGranularity: [
  312. {value:'10',label:'10分钟',key:'10'},
  313. {value:'30',label:'30分钟',key:'30'},
  314. {value:'60',label:'1小时',key:'60'},
  315. ],
  316. videoDuration: 0,
  317. startTime: null,
  318. processInterval: null,
  319. // ... 其他数据
  320. chatScrollTop: 0, // 保存聊天滚动位置,
  321. liveId: null,
  322. userTotal: {
  323. online: 0, // 在线总人数
  324. offline: 0, // 离线总人数
  325. silenced: 0 // 禁言总人数
  326. },
  327. // 各Tab的显示列表(仅存储当前需要展示的数据)
  328. onlineDisplayList: [], // 在线用户显示列表
  329. offlineDisplayList: [], // 离线用户显示列表
  330. silencedDisplayList: [], // 禁言用户显示列表
  331. // 各Tab的分页参数
  332. pageParams: {
  333. online: {
  334. currentPage: 1, // 当前页(下一页加载用)
  335. pageSize: 20, // 当前页(下一页加载用)
  336. prevPage: 0, // 上一页页码(上一页加载用)
  337. totalLoaded: 0, // 已加载总条数
  338. total: 0, // 总数据量
  339. hasMore: true, // 是否有下一页
  340. hasPrev: false // 是否有上一页
  341. },
  342. offline: {
  343. currentPage: 1,
  344. pageSize: 20,
  345. prevPage: 0,
  346. totalLoaded: 0,
  347. total: 0,
  348. hasMore: true,
  349. hasPrev: false
  350. },
  351. silenced: {
  352. currentPage: 1,
  353. pageSize: 20,
  354. prevPage: 0,
  355. totalLoaded: 0,
  356. total: 0,
  357. hasMore: true,
  358. hasPrev: false
  359. }
  360. },
  361. scrLoading: {
  362. online: { next: false, prev: false },
  363. offline: { next: false, prev: false },
  364. silenced: { next: false, prev: false }
  365. }
  366. }
  367. },
  368. created() {
  369. if (this.$route.params.liveId) {
  370. this.liveId = this.$route.params.liveId;
  371. }else {
  372. this.liveId = this.$route.query.liveId;
  373. }
  374. // this.getLiveVideo()
  375. this.getList()
  376. this.connectWebSocket()
  377. this.getLive()
  378. this.searchQuery.liveId = this.liveId
  379. },
  380. computed: {
  381. userId() {
  382. return this.$store.state.user.user.userId
  383. },
  384. companyId() {
  385. return this.$store.state.user.user.companyId
  386. },
  387. onlineLabel() {
  388. return `在线(${this.userTotal.online})`;
  389. },
  390. offlineLabel() {
  391. return `离线(${this.userTotal.offline})`;
  392. },
  393. silencedUserLabel() {
  394. return `禁言(${this.userTotal.silenced})`;
  395. }
  396. },
  397. mounted() {
  398. this.$nextTick(() => {
  399. this.restoreChatScrollPosition();
  400. });
  401. this.getEchartsTables();
  402. // 添加滚动事件监听器
  403. this.$nextTick(() => {
  404. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  405. this.$refs.manageRightRef.wrap.addEventListener('scroll', this.saveChatScrollPosition);
  406. }
  407. });
  408. this.initScrollListeners();
  409. },
  410. beforeDestroy() {
  411. this.saveTabScrollPositions()
  412. // 移除滚动监听(避免内存泄漏)
  413. const scrollRefs = {
  414. online: this.$refs.manageLeftRef_online,
  415. offline: this.$refs.manageLeftRef_offline,
  416. silenced: this.$refs.manageLeftRef_silenced
  417. };
  418. Object.keys(scrollRefs).forEach(tabName => {
  419. const scrollEl = scrollRefs[tabName]?.wrap;
  420. if (scrollEl) {
  421. scrollEl.removeEventListener('scroll', () =>
  422. this.handleTabScroll(tabName, scrollEl)
  423. );
  424. }
  425. })
  426. },
  427. // 使用 deactivated 和 activated 钩子替代 beforeDestroy 和 destroyed
  428. deactivated() {
  429. this.saveChatScrollPosition();
  430. },
  431. activated() {
  432. this.$nextTick(() => {
  433. this.restoreChatScrollPosition();
  434. });
  435. this.$nextTick(() => {
  436. const video = this.$refs.videoPlayer;
  437. if (video != null) {
  438. this.initVideoPlayer(this.liveInfo.startTime)
  439. }
  440. })
  441. },
  442. methods: {
  443. handleClickCoupon(){
  444. this.$router.push({
  445. name: 'LiveCoupon',
  446. query: {
  447. liveId: this.liveId
  448. }
  449. })
  450. },
  451. // 保存聊天滚动位置
  452. saveChatScrollPosition() {
  453. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  454. this.chatScrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight;
  455. }
  456. },
  457. // 恢复聊天滚动位置
  458. restoreChatScrollPosition() {
  459. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  460. this.$refs.manageRightRef.wrap.scrollTop = this.chatScrollTop;
  461. }
  462. },
  463. getLive(){
  464. getLive(this.liveId).then(res => {
  465. if (res.code == 200) {
  466. if (res.data.isAudit != 1) {
  467. this.$message.error("当前直播间未经审核");
  468. this.loading = false
  469. return
  470. }
  471. this.isAudit = true
  472. this.status = res.data.status
  473. if(res.data.status == 4){
  474. this.liveType = 3
  475. this.videoUrl = res.data.videoUrl;
  476. }else {
  477. if (res.data.status != 2) {
  478. this.$message.error("当前直播间未直播");
  479. this.loading = false
  480. return
  481. }
  482. if (res.data.liveType == 1) {
  483. this.livingUrl = res.data.flvHlsUrl
  484. this.livingUrl = this.livingUrl.replace("flv","m3u8")
  485. this.$nextTick(() => {
  486. this.initPlayer()
  487. })
  488. this.startTime = new Date(res.data.startTime).getTime()
  489. this.processInterval = setInterval(this.updateLiveProgress, 1000);
  490. } else {
  491. this.liveType = 2
  492. this.videoUrl = res.data.videoUrl;
  493. this.$nextTick(() => {
  494. this.initVideoPlayer(res.data.startTime)
  495. })
  496. }
  497. }
  498. this.loading = false
  499. } else {
  500. this.$message.error(res.msg)
  501. this.loading = false
  502. }
  503. this.liveInfo = res.data
  504. })
  505. },
  506. initVideoPlayer: function (startTime) {
  507. const video = this.$refs.videoPlayer;
  508. this.hls = new Hls();
  509. this.hls.attachMedia(video);
  510. this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
  511. this.hls.loadSource(this.videoUrl);
  512. this.hls.on(Hls.Events.STREAM_LOADED, (event, data) => {
  513. video.play();
  514. });
  515. });
  516. this.hls.on(Hls.Events.ERROR, (event, data) => {
  517. console.error('HLS 错误:', data);
  518. });
  519. // 1. 初始化开播时间
  520. startTime = new Date(startTime).getTime();
  521. this.startTime = startTime;
  522. // 2. 监听视频元数据加载完成(获取视频时长)
  523. video.addEventListener('loadedmetadata', () => {
  524. this.videoDuration = video.duration; // 获取视频时长(秒)
  525. // 初始化视频播放位置
  526. this.updateVideoPosition(video);
  527. // 启动实时进度更新(每秒刷新一次)
  528. this.processInterval = setInterval(this.updateProgress, 1000);
  529. });
  530. },
  531. updateVideoPosition(video){
  532. const currentTime = new Date().getTime(); // 当前时间戳(毫秒)
  533. const elapsedTime = currentTime - this.startTime; // 已流逝时间(毫秒)
  534. if (elapsedTime < 0) {
  535. // 未开播:视频停在初始位置
  536. video.currentTime = 0;
  537. return;
  538. }
  539. // 已开播:计算视频循环后的位置(流逝时间 % 视频时长)
  540. const elapsedSeconds = elapsedTime / 1000; // 转换为秒
  541. const videoPosition = elapsedSeconds % this.videoDuration; // 视频内的播放位置(秒)
  542. // 设置视频播放位置
  543. video.currentTime = videoPosition;
  544. },
  545. updateProgress() {
  546. const progressBar = this.$refs.progressBar;
  547. const elapsedTimeEl = this.$refs.elapsedTime;
  548. if (!this.videoDuration) return; // 视频时长未加载时不更新
  549. const currentTime = new Date().getTime();
  550. const elapsedTime = currentTime - this.startTime; // 总流逝时间(毫秒)
  551. if (elapsedTime < 0) {
  552. // 未开播状态
  553. progressBar.style.width = '0%';
  554. elapsedTimeEl.textContent = '00:00:00';
  555. return;
  556. }
  557. // 计算进度百分比(基于视频循环)
  558. const elapsedSeconds = elapsedTime / 1000;
  559. const videoPosition = elapsedSeconds % this.videoDuration; // 当前在视频中的位置
  560. const progressPercent = (videoPosition / this.videoDuration) * 100; // 进度百分比
  561. // 更新进度条宽度
  562. progressBar.style.width = `${progressPercent}%`;
  563. // 格式化总流逝时间为“时:分:秒”并显示
  564. elapsedTimeEl.textContent = this.formatTime(elapsedTime);
  565. },
  566. updateLiveProgress() {
  567. const elapsedTimeEl = this.$refs.liveElapsedTime;
  568. const currentTime = new Date().getTime();
  569. const elapsedTime = currentTime - this.startTime; // 总流逝时间(毫秒)
  570. if (elapsedTime < 0) {
  571. elapsedTimeEl.textContent = '00:00:00';
  572. return;
  573. }
  574. // 格式化总流逝时间为“时:分:秒”并显示
  575. elapsedTimeEl.textContent = this.formatTime(elapsedTime);
  576. },
  577. formatTime(ms) {
  578. const totalSeconds = Math.floor(ms / 1000);
  579. const hours = Math.floor(totalSeconds / 3600);
  580. const minutes = Math.floor((totalSeconds % 3600) / 60);
  581. const seconds = totalSeconds % 60;
  582. // 补零处理(确保两位数)
  583. return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
  584. },
  585. // ... 其他方法 ...
  586. timeChange(val) {
  587. this.searchQuery.timeOptions = val
  588. this.getEchartsTables(this.searchQuery)
  589. this.initChart()
  590. },
  591. timeGranularityChange(val) {
  592. this.searchQuery.timeGranularity = val
  593. this.getEchartsTables()
  594. this.initChart()
  595. },
  596. getEchartsTables() {
  597. getLiveOrderTimeGranularity(this.searchQuery).then(res => {
  598. if (res.code == 200) {
  599. this.echartsXLine = res.hourlySlots
  600. this.echartsXValue = res.hourlySlotsValue
  601. this.initChart()
  602. }
  603. })
  604. },
  605. initChart() {
  606. const chartDom = this.$refs.chartContainer;
  607. this.myChart = echarts.init(chartDom);
  608. const option = {
  609. tooltip: {trigger: 'axis'},
  610. legend: {data: ['订单数']},
  611. xAxis: {type: 'category', boundaryGap: false, data: this.echartsXLine},
  612. yAxis: {type: 'value'},
  613. series: [
  614. {name: '订单数', type: 'line', data: this.echartsXValue}
  615. ],
  616. };
  617. this.myChart.setOption(option);
  618. },
  619. handleClickRed(){
  620. this.$router.push({
  621. name: 'LiveRedConf',
  622. query: {
  623. liveId: this.liveId
  624. }
  625. })
  626. },
  627. handleClickLottery(){
  628. this.$router.push({
  629. name: 'LiveLotteryConf',
  630. query: {
  631. liveId: this.liveId
  632. }
  633. })
  634. },
  635. handleClickGoods(){
  636. this.$router.push({
  637. name: 'LiveGoods',
  638. query: {
  639. liveId: this.liveId
  640. }
  641. })
  642. },
  643. handleClickOrder(){
  644. this.$router.push({
  645. name: 'LiveOrder',
  646. query: {
  647. liveId: this.liveId
  648. }
  649. })
  650. },
  651. initPlayer(){
  652. var isUrl = this.livingUrl === null || this.livingUrl.trim() === ''
  653. if (isUrl) {
  654. console.error('直播地址为空,无法初始化播放器')
  655. return
  656. }
  657. if (Hls.isSupported() && !isUrl) {
  658. const videoElement = this.$refs.livingPlayer
  659. if (!videoElement) {
  660. console.error('找不到 video 元素')
  661. return
  662. }
  663. this.hls = new Hls();
  664. this.hls.attachMedia(videoElement);
  665. this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
  666. this.hls.loadSource(this.livingUrl);
  667. this.hls.on(Hls.Events.STREAM_LOADED, (event, data) => {
  668. videoElement.play();
  669. });
  670. });
  671. this.hls.on(Hls.Events.ERROR, (event, data) => {
  672. console.error('HLS 错误:', data);
  673. });
  674. } else {
  675. console.error('浏览器不支持 HLS')
  676. }
  677. },
  678. handleClick(tab) {
  679. const tabName = tab.name;
  680. const params = this.pageParams[tabName];
  681. const displayList = this[`${tabName}DisplayList`];
  682. // 首次切换到该Tab或列表为空时初始化
  683. if (displayList.length < 20) {
  684. // 重置分页参数
  685. params.currentPage = 1;
  686. params.pageSize = 20;
  687. params.prevPage = 0;
  688. params.totalLoaded = 0;
  689. params.hasMore = true;
  690. params.hasPrev = false;
  691. // 加载第一页
  692. this.loadNextPage(tabName);
  693. } else {
  694. // 非首次切换,恢复滚动位置
  695. this.$nextTick(() => {
  696. const scrollEl = this.getScrollElement(tabName);
  697. if (scrollEl) {
  698. scrollEl.scrollTop = this.tabScrollPositions[tabName] || 0;
  699. }
  700. });
  701. }
  702. },
  703. saveTabScrollPositions() {
  704. this.tabScrollPositions = {
  705. online: this.getScrollElement('online')?.scrollTop || 0,
  706. offline: this.getScrollElement('offline')?.scrollTop || 0,
  707. silenced: this.getScrollElement('silenced')?.scrollTop || 0
  708. };
  709. },
  710. // 加载指定Tab的用户列表(核心加载逻辑)
  711. loadNextPage(tabName) {
  712. const params = this.pageParams[tabName];
  713. const displayList = this[`${tabName}DisplayList`];
  714. console.log(`加载 ${tabName} 用户列表`)
  715. console.log(!params.hasMore || this.scrLoading[tabName].next)
  716. console.log(params.currentPage)
  717. // 若没有更多数据或正在加载,直接返回
  718. if (!params.hasMore || this.scrLoading[tabName].next) {
  719. return;
  720. }
  721. this.scrLoading[tabName].next = true;
  722. const queryParams = {
  723. liveId: this.liveId,
  724. pageNum: params.currentPage,
  725. pageSize: 20,
  726. online: tabName === 'online' ? 0 : 1,
  727. msgStatus: tabName === 'silenced' ? 1 : 0
  728. };
  729. // 调用接口加载对应状态的分页数据(需后端支持按状态筛选)
  730. watchUserList(queryParams).then(response => {
  731. this.scrLoading[tabName].next = false;
  732. if (response.code !== 200) return;
  733. const { rows, total } = response;
  734. params.total = total; // 记录总数据量
  735. // 过滤重复数据(基于userId)
  736. const newRows = rows.filter(row =>
  737. !displayList.some(u => u.userId === row.userId)
  738. );
  739. displayList.push(...newRows)
  740. // 添加新数据并限制最大长度(避免内存占用过大)
  741. if (displayList.length >= 40) { // 最大保留100条
  742. this[`${tabName}DisplayList`] = displayList.slice(-40);
  743. // 记录滚动位置(用于加载后校准)
  744. const scrollEl = this.getScrollElement(tabName);
  745. // 校准滚动位置(保持视觉连续性)
  746. this.$nextTick(() => {
  747. if (scrollEl) {
  748. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  749. }
  750. });
  751. }
  752. // 更新分页状态
  753. params.hasMore = params.currentPage * params.pageSize < total;
  754. params.currentPage += 1;
  755. params.hasPrev = params.currentPage > 2; // 当前页>2时一定有上一页
  756. params.prevPage = params.currentPage - 2;
  757. }).catch(() => {
  758. this.scrLoading[tabName].next = false;
  759. });
  760. },
  761. // 新增:加载上一页(向上滚动时)
  762. loadPrevPage(tabName) {
  763. const params = this.pageParams[tabName];
  764. const displayList = this[`${tabName}DisplayList`];
  765. // 边界校验:无上一页/正在加载/当前页<=1
  766. console.log(`加载 ${tabName} 上一页`);
  767. console.log(!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1)
  768. if (!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1) {
  769. return;
  770. }
  771. this.scrLoading[tabName].prev = true;
  772. const targetPage = params.prevPage > 0 ? params.prevPage : params.currentPage - 2;
  773. const queryParams = {
  774. liveId: this.liveId,
  775. pageNum: targetPage,
  776. pageSize: 20,
  777. online: tabName === 'online' ? 0 : 1,
  778. msgStatus: tabName === 'silenced' ? 1 : 0
  779. };
  780. watchUserList(queryParams).then(response => {
  781. this.scrLoading[tabName].prev = false;
  782. if (response.code !== 200) return;
  783. const { rows } = response;
  784. if (rows.length === 0) {
  785. params.hasPrev = false;
  786. return;
  787. }
  788. // 记录滚动位置(用于加载后校准)
  789. const scrollEl = this.getScrollElement(tabName);
  790. const scrollTop = scrollEl?.scrollTop || 0;
  791. const itemHeight = 80; // 预估行高(根据实际样式调整)
  792. const newItemsHeight = rows.length * itemHeight;
  793. // 过滤重复数据并添加到列表头部
  794. const newRows = rows.filter(row => !displayList.some(u => u.userId === row.userId));
  795. this[`${tabName}DisplayList`] = [...newRows, ...displayList];
  796. params.totalLoaded += newRows.length;
  797. // 限制最大长度
  798. if (this[`${tabName}DisplayList`].length > 40) {
  799. this[`${tabName}DisplayList`] = this[`${tabName}DisplayList`].slice(0, 40);
  800. }
  801. // 更新分页状态
  802. params.prevPage = targetPage - 1;
  803. params.hasPrev = targetPage > 1; // 上一页页码>1时还有更多上一页
  804. params.currentPage = params.currentPage - 1;
  805. if(params.currentPage * 20 < params.total) params.hasMore = true;
  806. // 校准滚动位置(保持视觉连续性)
  807. this.$nextTick(() => {
  808. if (scrollEl) {
  809. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  810. }
  811. });
  812. }).catch(() => {
  813. this.scrLoading[tabName].prev = false;
  814. });
  815. },
  816. // 辅助:获取Tab对应的滚动容器
  817. getScrollElement(tabName) {
  818. const scrollRefs = {
  819. online: this.$refs.manageLeftRef_online,
  820. offline: this.$refs.manageLeftRef_offline,
  821. silenced: this.$refs.manageLeftRef_silenced
  822. };
  823. return scrollRefs[tabName]?.wrap;
  824. },
  825. getList() {
  826. this.resetParams()
  827. // this.loadUserList()
  828. this.loadUserTotals(); // 先加载总人数
  829. // this.handleClick('online')
  830. this.handleClick({name:'online'})
  831. this.loadMsgList()
  832. },
  833. loadUserTotals() {
  834. if (!this.liveId) return;
  835. // 假设后端提供一个接口返回总人数(如果没有,可通过首次加载全量数据后统计)
  836. getLiveUserTotals({ liveId: this.liveId }).then(res => {
  837. if (res.code === 200) {
  838. this.userTotal = res.data; // { online, offline, silenced }
  839. }
  840. });
  841. },
  842. resetParams() {
  843. // 重置各Tab的显示列表和分页参数
  844. this.onlineDisplayList = [];
  845. this.offlineDisplayList = [];
  846. this.silencedDisplayList = [];
  847. this.pageParams = {
  848. online: {
  849. currentPage: 1, // 当前页(下一页加载用)
  850. pageSize: 20, // 当前页(下一页加载用)
  851. prevPage: 0, // 上一页页码(上一页加载用)
  852. totalLoaded: 0, // 已加载总条数
  853. total: 0, // 总数据量
  854. hasMore: true, // 是否有下一页
  855. hasPrev: false // 是否有上一页
  856. },
  857. offline: {
  858. currentPage: 1,
  859. pageSize: 20,
  860. prevPage: 0,
  861. totalLoaded: 0,
  862. total: 0,
  863. hasMore: true,
  864. hasPrev: false
  865. },
  866. silenced: {
  867. currentPage: 1,
  868. pageSize: 20,
  869. prevPage: 0,
  870. totalLoaded: 0,
  871. total: 0,
  872. hasMore: true,
  873. hasPrev: false
  874. }
  875. };
  876. // 消息参数保留
  877. this.msgList = [];
  878. this.msgParams = {
  879. pageNum: 1,
  880. pageSize: 10,
  881. liveId: this.liveId
  882. };
  883. },
  884. loadUserList() {
  885. if(this.liveId == null) return
  886. // 直播间用户
  887. watchUserList({
  888. liveId: this.liveId,
  889. pageNum: this.userParams.pageNum,
  890. pageSize: this.userParams.pageSize
  891. }).then(response => {
  892. let {code,rows,total} = response
  893. if (code === 200) {
  894. let totalPage = (total % this.userParams.pageSize == 0) ? Math.floor(total / this.userParams.pageSize) : Math.floor(total / this.userParams.pageSize + 1);
  895. rows.forEach(row => {
  896. if (!this.userList.some(u => u.userId === row.userId)) {
  897. this.userList.push(row)
  898. }
  899. })
  900. // 没加载完继续加载
  901. if (this.userParams.pageNum < totalPage) {
  902. this.userParams.pageNum = parseInt(this.userParams.pageNum) + 1;
  903. this.loadUserList()
  904. }
  905. }
  906. })
  907. },
  908. loadMsgList() {
  909. // 直播间消息
  910. listLiveMsg({
  911. liveId:this.liveId,
  912. pageNum: this.msgParams.pageNum,
  913. pageSize: this.msgParams.pageSize
  914. }).then(response => {
  915. let {code, rows,total} = response;
  916. if (code === 200) {
  917. let totalPage = (total % this.msgParams.pageSize == 0) ? Math.floor(total / this.msgParams.pageSize) : Math.floor(total / this.msgParams.pageSize + 1);
  918. rows.forEach(row => {
  919. if (!this.msgList.some(m => m.msgId === row.msgId)) {
  920. let user = this.userList.find(u => u.userId === row.userId)
  921. if (user) {
  922. row.msgStatus = user.msgStatus
  923. } else {
  924. row.msgStatus = 0
  925. }
  926. this.msgList.push(row)
  927. // 移动到底部
  928. this.$nextTick(() => {
  929. setTimeout(() => {
  930. this.$refs.manageRightRef.wrap.scrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight
  931. }, 200)
  932. })
  933. }
  934. })
  935. // 没加载完继续加载
  936. if (this.msgParams.pageNum < this.loadMsgMaxPage) {
  937. this.msgParams.pageNum = parseInt(this.msgParams.pageNum) + 1;
  938. this.loadMsgList()
  939. }
  940. // 同步更新消息列表中相同用户的状态
  941. this.userList.forEach(u => {
  942. this.msgList.filter(m => m.userId === u.userId).forEach(m => m.msgStatus = u.msgStatus)
  943. })
  944. }
  945. })
  946. // 添加滚动监听
  947. this.$nextTick(() => {
  948. this.$refs.manageRightRef.wrap.addEventListener("scroll", this.manageRightScroll)
  949. })
  950. },
  951. manageRightScroll() {
  952. this.saveChatScrollPosition();
  953. },
  954. blockUser(u){
  955. this.$confirm('是否确认封禁用户账号为:"' + u.nickName + '-' + u.userId + '"?', "警告", {
  956. confirmButtonText: "确定",
  957. cancelButtonText: "取消",
  958. type: "warning"
  959. }).then(function() {
  960. return blockUser(u.userId);
  961. }).then(() => {
  962. let msg = {
  963. msg: "",
  964. liveId: this.liveId,
  965. userId: u.userId,
  966. userType: 0,
  967. cmd: 'blockUser',
  968. avatar: this.$store.state.user.user.avatar,
  969. nickName: this.$store.state.user.user.nickName
  970. }
  971. this.socket.send(JSON.stringify(msg))
  972. this.msgSuccess("封禁成功");
  973. }).catch(() => {});
  974. },
  975. changeUserState(u) {
  976. // 修改状态
  977. changeUserStatus({liveId: u.liveId, userId: u.userId}).then(response => {
  978. let { code } = response;
  979. if (200 === code) {
  980. u.msgStatus = u.msgStatus === 0 ? 1 : 0
  981. // 同步更新消息列表中相同用户的状态
  982. this.msgList.forEach(msg => {
  983. if (msg.userId === u.userId) {
  984. msg.msgStatus = u.msgStatus;
  985. }
  986. });
  987. this.userList.forEach(user => {
  988. if (user.userId === u.userId) {
  989. user.msgStatus = u.msgStatus;
  990. }
  991. });
  992. // 4. 关键:重新筛选所有Tab的显示列表,确保状态同步
  993. this.refreshUserDisplayLists(u);
  994. let msg = u.msgStatus === 0 ? "已解禁" : "已禁言"
  995. this.msgSuccess(msg);
  996. return
  997. }
  998. this.msgError("操作失败");
  999. })
  1000. },
  1001. // 新增:重新筛选所有Tab的显示列表
  1002. refreshUserDisplayLists(user) {
  1003. const { userId, msgStatus: newStatus, online } = user;
  1004. const oldStatus = newStatus === 1 ? 0 : 1; // 操作前的状态(反向)
  1005. // 1. 禁言操作(newStatus=1):从原在线/离线列表移除,加入禁言列表
  1006. if (newStatus === 1) {
  1007. // 从在线/离线列表中移除该用户(根据当前在线状态)
  1008. if (online === 0) {
  1009. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== userId);
  1010. this.userTotal.online = Math.max(0, this.userTotal.online - 1);
  1011. } else {
  1012. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== userId);
  1013. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1);
  1014. }
  1015. this.userTotal.silenced = this.userTotal.silenced + 1;
  1016. // 添加到禁言列表(去重+限制长度)
  1017. const silencedList = this.silencedDisplayList.filter(u => u.userId !== userId);
  1018. silencedList.push(user);
  1019. this.silencedDisplayList = silencedList.slice(-40);
  1020. }
  1021. // 2. 解禁操作(newStatus=0):从禁言列表移除,回到原在线/离线列表
  1022. else {
  1023. // 从禁言列表移除
  1024. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== userId);
  1025. this.userTotal.silenced = Math.max(0, this.userTotal.silenced - 1);
  1026. // 回到对应的在线/离线列表(根据当前在线状态)
  1027. if (online === 0) {
  1028. // 加入在线列表(去重+限制长度)
  1029. const onlineList = this.onlineDisplayList.filter(u => u.userId !== userId);
  1030. onlineList.push(user);
  1031. this.onlineDisplayList = onlineList.slice(-40);
  1032. this.userTotal.online = this.userTotal.online + 1;
  1033. } else {
  1034. // 加入离线列表(去重+限制长度)
  1035. const offlineList = this.offlineDisplayList.filter(u => u.userId !== userId);
  1036. offlineList.push(user);
  1037. this.offlineDisplayList = offlineList.slice(-40);
  1038. this.userTotal.offline = this.userTotal.offline + 1;
  1039. }
  1040. }
  1041. },
  1042. connectWebSocket() {
  1043. this.$store.dispatch('initLiveWs', {
  1044. liveWsUrl: this.liveWsUrl,
  1045. liveId: this.liveId,
  1046. userId: this.userId
  1047. })
  1048. this.socket = this.$store.state.liveWs[this.liveId]
  1049. this.socket.onmessage = (event) => this.handleWsMessage(event)
  1050. },
  1051. handleWsMessage(event) {
  1052. let { code, data } = JSON.parse(event.data)
  1053. if (code === 200) {
  1054. let { cmd } = data
  1055. if (cmd === 'sendMsg') {
  1056. let message = JSON.parse(data.data)
  1057. let user = this.userList.find(u => u.userId === message.userId)
  1058. if (user) {
  1059. message.msgStatus = user.msgStatus
  1060. } else {
  1061. message.msgStatus = 0
  1062. }
  1063. delete message.params
  1064. if(this.msgList.length > 50){
  1065. this.msgList.shift()
  1066. }
  1067. this.msgList.push(message)
  1068. // 移动到底部
  1069. this.$nextTick(() => {
  1070. setTimeout(() => {
  1071. this.$refs.manageRightRef.wrap.scrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight
  1072. }, 200)
  1073. })
  1074. } else if (cmd === 'entry' || cmd === 'out') {
  1075. const user = data;
  1076. const online = cmd === 'entry' ? 0 : 1; // 0=在线,1=离线
  1077. const info = {
  1078. online:online,
  1079. msgStatus: user.msgStatus || 0,
  1080. nickName: user.nickName || '',
  1081. userType: user.userType || 0,
  1082. userId: user.userId || '',
  1083. };
  1084. // 1. 更新总人数(在线/离线互转)
  1085. if (cmd === 'entry') {
  1086. this.userTotal.online += 1;
  1087. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1); // 确保不小于0
  1088. } else {
  1089. this.userTotal.offline += 1;
  1090. this.userTotal.online = Math.max(0, this.userTotal.online - 1); // 确保不小于0
  1091. }
  1092. // 2. 强制更新相关列表(无论当前激活哪个Tab)
  1093. if (cmd === 'entry') {
  1094. // 用户进入:从离线列表删除,添加到在线列表
  1095. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  1096. const newOnlineList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  1097. newOnlineList.push(info);
  1098. this.onlineDisplayList = newOnlineList.slice(-40); // 限制最大50条
  1099. } else {
  1100. // 用户离开:从在线列表删除,添加到离线列表
  1101. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  1102. const newOfflineList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  1103. newOfflineList.push(info);
  1104. this.offlineDisplayList = newOfflineList.slice(-40); // 限制最大50条
  1105. }
  1106. // 3. 处理禁言列表(如果用户是禁言状态,无论进出都要同步)
  1107. if (info.msgStatus === 1) {
  1108. // 禁言用户:从禁言列表删除旧数据,添加新数据
  1109. const newSilencedList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  1110. newSilencedList.push(info);
  1111. this.silencedDisplayList = newSilencedList.slice(-40);
  1112. } else {
  1113. // 非禁言用户:从禁言列表删除(如果存在)
  1114. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  1115. }
  1116. } else if (cmd === 'live_start') {
  1117. } else if (cmd === 'live_end') {
  1118. }
  1119. }
  1120. },
  1121. sendMessage() {
  1122. // 发送前简单校验
  1123. if (this.newMsg.trim() === '') {
  1124. return;
  1125. }
  1126. let msg = {
  1127. msg: this.newMsg,
  1128. liveId: this.liveId,
  1129. userId: this.userId,
  1130. userType: 1,
  1131. cmd: 'sendMsg',
  1132. avatar: this.$store.state.user.user.avatar,
  1133. nickName: this.$store.state.user.user.nickName
  1134. }
  1135. this.socket.send(JSON.stringify(msg))
  1136. this.newMsg = '';
  1137. },
  1138. // 初始化滚动监听(在mounted中调用)
  1139. initScrollListeners() {
  1140. // 为每个Tab的滚动容器添加监听
  1141. this.$nextTick(() => {
  1142. const scrollRefs = {
  1143. online: this.$refs.manageLeftRef_online,
  1144. offline: this.$refs.manageLeftRef_offline,
  1145. silenced: this.$refs.manageLeftRef_silenced
  1146. };
  1147. Object.keys(scrollRefs).forEach(tabName => {
  1148. const scrollEl = scrollRefs[tabName]?.wrap;
  1149. if (scrollEl) {
  1150. scrollEl.addEventListener('scroll', () =>
  1151. this.handleTabScroll(tabName, scrollEl)
  1152. );
  1153. }
  1154. });
  1155. });
  1156. },
  1157. // 处理Tab滚动事件(判断是否触底)
  1158. handleTabScroll(tabName, scrollEl) {
  1159. const { scrollTop, scrollHeight, clientHeight } = scrollEl;
  1160. const bottomThreshold = 50; // 距离底部100px触发下一页
  1161. const topThreshold = 50; // 距离顶部100px触发上一页
  1162. // 加载下一页(滚动到底部附近)
  1163. if (scrollHeight - scrollTop - clientHeight < bottomThreshold) {
  1164. this.loadNextPage(tabName);
  1165. }
  1166. // 加载上一页(滚动到顶部附近)
  1167. if (scrollTop < topThreshold) {
  1168. this.loadPrevPage(tabName);
  1169. }
  1170. },
  1171. },
  1172. destroyed() {
  1173. this.hls?.destroy();
  1174. clearInterval(this.processInterval)
  1175. }
  1176. }
  1177. </script>
  1178. <style scoped>
  1179. .talk-list{
  1180. display: flex;
  1181. }
  1182. .live-console {
  1183. width: 90vw;
  1184. padding: 10px 0;
  1185. background-color: #f5f4f4;
  1186. }
  1187. .live-console .live-console-col {
  1188. height: 88vh;
  1189. margin-left: 5px;
  1190. padding: 0 10px;
  1191. background-color: white;
  1192. border-radius: 4px;
  1193. }
  1194. /*隐藏水平滚动条*/
  1195. ::v-deep .el-scrollbar__wrap {
  1196. overflow-x: hidden;
  1197. }
  1198. /* 消息输入区域 */
  1199. .chat-input {
  1200. display: flex;
  1201. padding: 10px;
  1202. border-top: 1px solid #ebeef5;
  1203. background-color: #fff;
  1204. min-height: 120px;
  1205. }
  1206. .chat-input .el-input {
  1207. flex: 1;
  1208. margin-right: 10px;
  1209. }
  1210. .chat-input .el-textarea__inner {
  1211. resize: none;
  1212. min-height: 100px;
  1213. }
  1214. ::v-deep .el-textarea__inner {
  1215. border: none !important;
  1216. box-shadow: none !important;
  1217. resize: none !important;
  1218. }
  1219. ::v-deep .el-textarea__inner:focus {
  1220. border: none !important;
  1221. box-shadow: none !important;
  1222. }
  1223. ::v-deep .el-tabs__item {
  1224. padding: 0;
  1225. }
  1226. .live-console-tab-left ::v-deep .el-tabs__active-bar {
  1227. width: 41px!important;
  1228. margin-left: calc((100% / 1 - 41px) / 2);
  1229. height: 4px;
  1230. border-radius: 4px;
  1231. }
  1232. /* calc 3是tab数量 */
  1233. .live-console-tab-right ::v-deep .el-tabs__active-bar {
  1234. width: 51px!important;
  1235. margin-left: calc((100% / 3 - 51px) / 2);
  1236. height: 4px;
  1237. border-radius: 4px;
  1238. }
  1239. .custom-video {
  1240. pointer-events: none !important; /* 完全禁止鼠标交互,避免悬停时显示工具栏 */
  1241. outline: none !important; /* 移除焦点轮廓 */
  1242. }
  1243. /* 额外的兼容性隐藏 */
  1244. .custom-video::-webkit-media-controls {
  1245. display: none !important;
  1246. }
  1247. .custom-video::-webkit-media-controls-panel {
  1248. display: none !important;
  1249. }
  1250. /* 进度条容器 */
  1251. .progress-container {
  1252. width: 100%;
  1253. height: 6px;
  1254. background: #eee;
  1255. border-radius: 3px;
  1256. margin: 10px 0;
  1257. cursor: pointer;
  1258. }
  1259. /* 进度条填充部分 */
  1260. .progress-bar {
  1261. height: 100%;
  1262. background: #42b983; /* Vue 绿色主题示例 */
  1263. border-radius: 3px;
  1264. width: 0%; /* 初始进度为 0 */
  1265. }
  1266. /* 时间显示样式 */
  1267. .time-display {
  1268. color: #ffffff;
  1269. font-size: 14px;
  1270. }
  1271. </style>