LiveConsole.vue 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789
  1. <template>
  2. <div class="console">
  3. <div class="left-panel">
  4. <h2>学员列表</h2>
  5. <div class="search">
  6. <input type="text" placeholder="搜索用户昵称" v-model="searchKeyword">
  7. <button @click="searchUsers()">搜索</button>
  8. </div>
  9. <el-tabs class="live-console-tab-right" v-model="tabLeft.activeName" @tab-click="handleUserClick" :stretch="true">
  10. <el-tab-pane :label="alLabel" name="al">
  11. <el-scrollbar class="custom-scrollbar" ref="manageLeftRef_al" style="height: 800px; width: 100%;">
  12. <el-row class='scrollbar-demo-item' v-for="u in alDisplayList" :key="u.userId">
  13. <el-col :span="20">
  14. <el-row type="flex" align="middle">
  15. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  16. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  17. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  18. </el-row>
  19. </el-col>
  20. <el-col :span="4" >
  21. <el-popover
  22. width="100"
  23. trigger="click">
  24. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  25. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  26. <i class="el-icon-more" slot="reference"></i>
  27. </el-popover>
  28. </el-col>
  29. </el-row>
  30. </el-scrollbar>
  31. </el-tab-pane>
  32. <el-tab-pane :label="onlineLabel" name="online">
  33. <el-scrollbar class="custom-scrollbar" ref="manageLeftRef_online" style="height: 800px; width: 100%;">
  34. <el-row class='scrollbar-demo-item' v-for="u in onlineDisplayList" :key="u.userId">
  35. <el-col :span="20">
  36. <el-row type="flex" align="middle">
  37. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  38. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  39. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  40. </el-row>
  41. </el-col>
  42. <el-col :span="4" >
  43. <el-popover
  44. width="100"
  45. trigger="click">
  46. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  47. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  48. <i class="el-icon-more" slot="reference"></i>
  49. </el-popover>
  50. </el-col>
  51. </el-row>
  52. </el-scrollbar>
  53. </el-tab-pane>
  54. <el-tab-pane :label="offlineLabel" name="offline">
  55. <el-scrollbar class="custom-scrollbar" ref="manageLeftRef_offline" style="height: 800px; width: 100%;">
  56. <el-row class='scrollbar-demo-item' v-for="u in offlineDisplayList" :key="u.userId">
  57. <el-col :span="20">
  58. <el-row type="flex" align="middle">
  59. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  60. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  61. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  62. </el-row>
  63. </el-col>
  64. <el-col :span="4" >
  65. <el-popover
  66. width="100"
  67. trigger="click">
  68. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  69. <i class="el-icon-more" slot="reference"></i>
  70. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  71. </el-popover>
  72. </el-col>
  73. </el-row>
  74. </el-scrollbar>
  75. </el-tab-pane>
  76. <el-tab-pane :label="silencedUserLabel" name="silenced">
  77. <el-scrollbar class="custom-scrollbar" ref="manageLeftRef_silenced" style="height: 800px; width: 100%;">
  78. <el-row class='scrollbar-demo-item' v-for="u in silencedDisplayList" :key="u.userId">
  79. <el-col :span="20">
  80. <el-row type="flex" align="middle">
  81. <el-col :span="4" style="padding-left: 10px;"><el-avatar :src="u.avatar"></el-avatar></el-col>
  82. <el-col :span="19" :offset="1">{{ u.nickName }}</el-col>
  83. <el-col :span="19" :offset="1">{{ u.userId }}</el-col>
  84. </el-row>
  85. </el-col>
  86. <el-col :span="4" >
  87. <el-popover
  88. width="100"
  89. trigger="click">
  90. <a style="cursor: pointer;color: #ff0000;" @click="changeUserState(u)">{{ u.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  91. <a style="cursor: pointer;color: #ff0000;margin-left:10px" @click="blockUser(u)">拉黑</a>
  92. <i class="el-icon-more" slot="reference"></i>
  93. </el-popover>
  94. </el-col>
  95. </el-row>
  96. </el-scrollbar>
  97. </el-tab-pane>
  98. </el-tabs>
  99. </div>
  100. <div class="middle-panel">
  101. <h2>消息管理</h2>
  102. <div class="discussion-messages">
  103. <h3>讨论区消息</h3>
  104. <div class="message-settings">
  105. <label>
  106. <input type="checkbox" v-model="globalVisible" @change="globalVisibleChange">
  107. 全局用户自见
  108. </label>
  109. </div>
  110. <div class="message-container" @click="handleMessageBoxClick">
  111. <el-scrollbar class="custom-scrollbar" style="height: 500px; width: 100%;" ref="manageRightRef">
  112. <el-row v-for="m in msgList" :key="m.msgId">
  113. <el-row v-if="m.userId === userId && m.msgId == null" style="padding: 8px 0" type="flex" align="top" justify="end">
  114. <div style="display: flex;justify-content: flex-end">
  115. <div style="display: flex;justify-content: flex-end;flex-direction: column;max-width: 200px;align-items: flex-end">
  116. <div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div>
  117. <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>
  118. </div>
  119. <el-avatar :src="m.avatar" style="margin-left: 10px; margin-right: 10px;"/>
  120. </div>
  121. </el-row>
  122. <el-row v-else style="margin-top: 5px" type="flex" align="top" >
  123. <el-col :span="3" style="margin-left: 10px"><el-avatar :src="m.avatar"/></el-col>
  124. <el-col :span="15">
  125. <el-row style="margin-left: 10px">
  126. <el-col><div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div></el-col>
  127. <el-col :span="24" style="max-width: 200px;">
  128. <div style="white-space: normal; word-wrap: break-word;background-color: #f0f2f5; padding: 8px; border-radius: 5px;font-size: 14px;width: 100%;">
  129. {{ m.msg }}
  130. </div>
  131. </el-col>
  132. <el-col>
  133. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="changeUserState(m)">{{ m.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  134. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="blockUser(m)">拉黑</a>
  135. <a v-if="m.singleVisible === 1" style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">解除用户自见</a>
  136. <a v-else style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">用户自见</a>
  137. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="deleteMsg(m)">删除</a>
  138. </el-col>
  139. </el-row>
  140. </el-col>
  141. </el-row>
  142. </el-row>
  143. <!-- 底部留白 -->
  144. <div style="height: 20px;"></div>
  145. </el-scrollbar>
  146. <!-- 加载最新消息按钮 -->
  147. <el-button
  148. v-if="showLoadLatestBtn"
  149. class="load-latest-btn"
  150. type="primary"
  151. size="small"
  152. @click.stop="loadLatestMessages"
  153. :disabled="isLoadingLatest"
  154. :loading="isLoadingLatest"
  155. icon="el-icon-refresh">
  156. 加载最新消息
  157. </el-button>
  158. </div>
  159. <!-- <div class="message-list">-->
  160. <!-- <div class="message-item" v-for="msg in msgList" :key="msg.id">-->
  161. <!-- <div class="message-avatar">-->
  162. <!-- <img :src="msg.avatar" alt="用户头像">-->
  163. <!-- </div>-->
  164. <!-- <div class="message-content">-->
  165. <!-- <div class="message-user">{{ msg.user }}</div>-->
  166. <!-- <div class="message-text">{{ msg.text }}</div>-->
  167. <!-- </div>-->
  168. <!-- <div class="message-actions">-->
  169. <!--&lt;!&ndash; <button @click="toggleVisible(msg)">&ndash;&gt;-->
  170. <!--&lt;!&ndash; {{ msg.isVisible ? '仅用户自见' : '全局可见' }}&ndash;&gt;-->
  171. <!--&lt;!&ndash; </button>&ndash;&gt;-->
  172. <!-- <button @click="deleteMessage(msg)">删除</button>-->
  173. <!-- </div>-->
  174. <!-- </div>-->
  175. <!-- </div>-->
  176. </div>
  177. <div class="system-messages">
  178. <h3>系统消息</h3>
  179. <textarea placeholder="输入系统消息" v-model="newMsg"></textarea>
  180. <div class="message-actions">
  181. <button @click="sendMessage">发送消息</button>
  182. <button @click="sendPopMessage">弹窗消息</button>
  183. <button @click="showTopMsgDialog">顶部消息</button>
  184. </div>
  185. </div>
  186. </div>
  187. <div class="right-panel">
  188. <h2>运营工具</h2>
  189. <div class="live-player">
  190. <h3>直播观看</h3>
  191. <LivePlayer ref="livePlayer" :videoParam="videoParam" />
  192. </div>
  193. <div class="automation">
  194. <h3>运营自动化</h3>
  195. <div class="timeline">
  196. <h4>时间轴设置</h4>
  197. <div class="timeline-items">
  198. <div class="timeline-item" v-for="item in timelineItems.slice(0,2)" :key="item.time">
  199. <div class="time">{{ formatDate(item.absValue) }}</div>
  200. <div class="action">{{ item.taskName }}</div>
  201. <button class="delete" @click="removeTimelineItem(item)">删除</button>
  202. </div>
  203. </div>
  204. <!-- <button class="add" @click="addTimelineItem">添加时间节点</button>-->
  205. </div>
  206. </div>
  207. <div class="watermark">
  208. <h3>直播氛围自动化</h3>
  209. <div class="watermark-settings">
  210. <textarea :disabled="autoWatermark" v-model="watermarkTemplate" placeholder="水军弹幕内容模板,每行一条"></textarea>
  211. <div class="watermark-options">
  212. <label>
  213. 发送间隔:
  214. <input type="number" :disabled="autoWatermark" v-model.number="interval" min="1">
  215. </label>
  216. <label>
  217. <input type="checkbox" v-model="autoWatermark" @change="changeAutoWatermark">
  218. 启用水军自动化
  219. </label>
  220. </div>
  221. </div>
  222. </div>
  223. </div>
  224. <!-- 顶部消息对话框 -->
  225. <el-dialog title="发送顶部消息" :visible.sync="topMsgDialogVisible" width="500px">
  226. <el-form :model="topMsgForm" label-width="100px">
  227. <el-form-item label="消息内容">
  228. <el-input
  229. type="textarea"
  230. v-model="topMsgForm.msg"
  231. placeholder="请输入消息内容"
  232. :rows="3"
  233. ></el-input>
  234. </el-form-item>
  235. <el-form-item label="显示时长">
  236. <el-input-number
  237. v-model="topMsgForm.duration"
  238. :min="1"
  239. :max="60"
  240. placeholder="请输入显示时长(分钟)"
  241. ></el-input-number>
  242. <span style="margin-left: 10px;">分钟</span>
  243. </el-form-item>
  244. </el-form>
  245. <div slot="footer" class="dialog-footer">
  246. <el-button @click="topMsgDialogVisible = false">取 消</el-button>
  247. <el-button type="primary" @click="sendTopMessage">确 定</el-button>
  248. </div>
  249. </el-dialog>
  250. </div>
  251. </template>
  252. <script>
  253. import LivePlayer from './LivePlayer.vue';
  254. import {blockUser, changeUserStatus, getLiveUserTotals, dashBoardWatchUserList} from '@/api/live/liveWatchUser'
  255. import { listLiveSingleMsg,delLiveMsg } from '@/api/live/liveMsg'
  256. import { getLive } from '@/api/live/live'
  257. import { consoleList } from '@/api/live/task'
  258. import ScreenScale from './ScreenScale.vue'; // 路径根据实际位置调整
  259. export default {
  260. components: {
  261. LivePlayer,ScreenScale
  262. },
  263. props: {
  264. liveId: {
  265. type: String,
  266. default: null
  267. }
  268. },
  269. data() {
  270. return {
  271. watermarkIndex: 0,
  272. watermarkList:[],
  273. watermarkTemplate: '',
  274. liveInfo: {},
  275. videoParam:{
  276. startTime:'',
  277. livingUrl: '',
  278. liveType: 1,
  279. videoUrl: '',
  280. },
  281. msgList:[],
  282. currentTab: 'al',
  283. searchKeyword: '',
  284. globalVisible: false,
  285. interval: 10,
  286. autoWatermark: false,
  287. streamUrl: 'rtmp://your-live-stream-url',
  288. users: [
  289. { id: 1, name: '用户1', avatar: 'https://via.placeholder.com/40', status: 'online', statusText: '在线', silenced: false, msgStatus: false },
  290. { id: 2, name: '用户2', avatar: 'https://via.placeholder.com/40', status: 'online', statusText: '在线', silenced: false, msgStatus: true },
  291. { id: 3, name: '用户3', avatar: 'https://via.placeholder.com/40', status: 'offline', statusText: '离线', silenced: true, msgStatus: false },
  292. { id: 4, name: '用户4', avatar: 'https://via.placeholder.com/40', status: 'online', statusText: '在线', silenced: false, msgStatus: false },
  293. { id: 5, name: '用户5', avatar: 'https://via.placeholder.com/40', status: 'offline', statusText: '离线', silenced: false, msgStatus: false }
  294. ],
  295. messages: [
  296. { id: 1, user: '用户1', avatar: 'https://via.placeholder.com/30', text: '这个产品怎么样?', isVisible: true },
  297. { id: 2, user: '用户2', avatar: 'https://via.placeholder.com/30', text: '看起来不错', isVisible: true },
  298. { id: 3, user: '用户3', avatar: 'https://via.placeholder.com/30', text: '有优惠吗?', isVisible: true }
  299. ],
  300. timelineItems: [
  301. { "searchValue": null,
  302. "createBy": null,
  303. "createTime": "2025-09-23 10:36:17",
  304. "updateBy": null,
  305. "updateTime": "2025-10-17 09:18:00",
  306. "remark": null,
  307. "params": {},
  308. "id": 6573,
  309. "liveId": 128,
  310. "taskName": "2",
  311. "taskType": 1,
  312. "triggerType": 2,
  313. "triggerValue": "2025-01-01T00:02:00.000+0800",
  314. "absValue": "2025-11-23T10:43:00.000+0800",
  315. "status": 1,
  316. "finishStatus": 0 },
  317. ],
  318. userTotal: {
  319. online: 0, // 在线总人数
  320. offline: 0, // 离线总人数
  321. silenced: 0, // 禁言总人数
  322. al: 0
  323. },
  324. tabLeft: {
  325. activeName: "al",
  326. },
  327. taskParams:{
  328. currentPage: 1,
  329. pageSize: 20,
  330. prevPage: 0,
  331. totalLoaded: 0,
  332. total: 0,
  333. hasMore: true,
  334. hasPrev: false
  335. },
  336. loadMsgMaxPage: 2,
  337. liveWsUrl: process.env.VUE_APP_LIVE_WS_URL + '/app/webSocket',
  338. alDisplayList: [],
  339. onlineDisplayList: [], // 在线用户显示列表
  340. offlineDisplayList: [], // 离线用户显示列表
  341. silencedDisplayList: [], // 禁言用户显示列表
  342. // 各Tab的分页参数
  343. pageParams: {
  344. al: {
  345. currentPage: 1,
  346. pageSize: 20,
  347. prevPage: 0,
  348. totalLoaded: 0,
  349. total: 0,
  350. hasMore: true,
  351. hasPrev: false
  352. },
  353. online: {
  354. currentPage: 1, // 当前页(下一页加载用)
  355. pageSize: 20, // 当前页(下一页加载用)
  356. prevPage: 0, // 上一页页码(上一页加载用)
  357. totalLoaded: 0, // 已加载总条数
  358. total: 0, // 总数据量
  359. hasMore: true, // 是否有下一页
  360. hasPrev: false // 是否有上一页
  361. },
  362. offline: {
  363. currentPage: 1,
  364. pageSize: 20,
  365. prevPage: 0,
  366. totalLoaded: 0,
  367. total: 0,
  368. hasMore: true,
  369. hasPrev: false
  370. },
  371. silenced: {
  372. currentPage: 1,
  373. pageSize: 20,
  374. prevPage: 0,
  375. totalLoaded: 0,
  376. total: 0,
  377. hasMore: true,
  378. hasPrev: false
  379. }
  380. },
  381. scrLoading: {
  382. al: { next: false, prev: false },
  383. online: { next: false, prev: false },
  384. offline: { next: false, prev: false },
  385. silenced: { next: false, prev: false }
  386. },
  387. newMsg:'',
  388. autoMsgTimer: null,
  389. checkInterval: 2000, // 检查间隔(1分钟,可根据需求调整)
  390. topMsgDialogVisible: false,
  391. topMsgForm: {
  392. msg: '',
  393. duration: 5
  394. },
  395. // 消息滚动控制
  396. isAutoScrollEnabled: true, // 是否启用自动滚动
  397. autoScrollTimer: null, // 自动滚动定时器
  398. showLoadLatestBtn: false, // 是否显示加载最新消息按钮
  399. isLoadingLatest: false, // 是否正在加载最新消息
  400. scrollDebounceTimer: null, // 滚动防抖定时器
  401. msgParams: {
  402. pageNum: 1,
  403. pageSize: 30,
  404. liveId: null
  405. }
  406. };
  407. },
  408. computed: {
  409. userId() {
  410. return this.$store.state.user.user.userId
  411. },
  412. companyId() {
  413. return this.$store.state.user.user.companyId
  414. },
  415. onlineLabel() {
  416. return `在线(${this.userTotal.online})`;
  417. },
  418. alLabel() {
  419. return `全部(${this.userTotal.al})`;
  420. },
  421. offlineLabel() {
  422. return `离线(${this.userTotal.offline})`;
  423. },
  424. silencedUserLabel() {
  425. return `禁言(${this.userTotal.silenced})`;
  426. }
  427. },
  428. created() {
  429. if(!this.liveId) return
  430. this.getList()
  431. this.handleUserClick({name:'al'})
  432. this.connectWebSocket()
  433. this.getLive()
  434. },
  435. mounted() {
  436. this.$nextTick(() => {
  437. this.restoreChatScrollPosition();
  438. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  439. this.$refs.manageRightRef.wrap.addEventListener('scroll', this.saveChatScrollPosition);
  440. }
  441. });
  442. this.initScrollListeners();
  443. },
  444. methods: {
  445. // 点击消息框
  446. handleMessageBoxClick() {
  447. // 点击消息框时,停止自动滚动
  448. this.isAutoScrollEnabled = false;
  449. // 停止自动滚动定时器
  450. if (this.autoScrollTimer) {
  451. clearTimeout(this.autoScrollTimer);
  452. this.autoScrollTimer = null;
  453. }
  454. // 检查是否在底部,如果不在底部则显示加载最新消息按钮
  455. this.$nextTick(() => {
  456. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  457. const wrap = this.$refs.manageRightRef.wrap;
  458. const scrollHeight = wrap.scrollHeight;
  459. const clientHeight = wrap.clientHeight;
  460. const currentScrollTop = wrap.scrollTop;
  461. const maxScrollTop = scrollHeight - clientHeight;
  462. if (currentScrollTop < maxScrollTop - 50) {
  463. this.showLoadLatestBtn = true;
  464. }
  465. }
  466. });
  467. },
  468. // 滚动到底部
  469. scrollToBottom(forceScroll = false) {
  470. // 如果自动滚动被禁用且不是强制滚动,则不执行
  471. if (!this.isAutoScrollEnabled && !forceScroll) {
  472. return;
  473. }
  474. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  475. this.$nextTick(() => {
  476. const wrap = this.$refs.manageRightRef.wrap;
  477. if (!wrap) return;
  478. const scrollHeight = wrap.scrollHeight;
  479. const clientHeight = wrap.clientHeight;
  480. const currentScrollTop = wrap.scrollTop;
  481. const maxScrollTop = scrollHeight - clientHeight;
  482. // 强制滚动或启用自动滚动时,直接滚动到底部并隐藏按钮
  483. if (forceScroll || this.isAutoScrollEnabled) {
  484. this.showLoadLatestBtn = false;
  485. wrap.scrollTop = maxScrollTop;
  486. }
  487. });
  488. }
  489. },
  490. // 加载最新消息
  491. loadLatestMessages() {
  492. // 如果正在加载,直接返回
  493. if (this.isLoadingLatest) {
  494. return;
  495. }
  496. this.isLoadingLatest = true;
  497. this.showLoadLatestBtn = false;
  498. // 恢复自动滚动
  499. this.isAutoScrollEnabled = true;
  500. // 重新请求最新消息
  501. this.resetMsgParams();
  502. // loadMsgList 中会自动滚动到底部,因为 isAutoScrollEnabled 已经是 true
  503. this.loadMsgList();
  504. },
  505. singleVisible(m){
  506. // 过滤当前所有消息 找到userId的相同的消息 更改他们的自可见状态
  507. m.singleVisible= m.singleVisible === 1 ? 0 : 1
  508. this.msgList.forEach(m1 => {m1.singleVisible = m1.userId === m.userId ? m.singleVisible : !m.singleVisible})
  509. // 消息自可见
  510. let msg = {
  511. liveId: this.liveId,
  512. userId: m.userId,
  513. userType: 0,
  514. cmd: 'singleVisible',
  515. status:m.singleVisible
  516. }
  517. this.socket.send(JSON.stringify(msg))
  518. },
  519. deleteMsg(m){
  520. // 1. 弹出确认对话框
  521. this.$confirm('此操作将永久删除该消息, 是否继续?', '提示', {
  522. confirmButtonText: '确定',
  523. cancelButtonText: '取消',
  524. type: 'warning'
  525. }).then(() => {
  526. delLiveMsg(m.msgId).then(res => {
  527. if (res.code === 200) {
  528. const index = this.msgList.findIndex(item => item.msgId === m.msgId);
  529. if (index !== -1) {
  530. this.msgList.splice(index, 1);
  531. console.log(`消息 ${m.msgId} 已在本地删除。`);
  532. }
  533. let msg = {
  534. liveId: this.liveId,
  535. userId: m.userId,
  536. msg: m.msgId, // 关键:将消息ID发送给后台
  537. cmd: 'deleteMsg',
  538. };
  539. this.socket.send(JSON.stringify(msg));
  540. // 可以在这里给用户一个删除成功的提示
  541. this.$message({
  542. type: 'success',
  543. message: '消息删除成功!'
  544. });
  545. }
  546. })
  547. }).catch(() => {
  548. // 3. 用户点击“取消”或关闭对话框后的回调
  549. this.$message({
  550. type: 'info',
  551. message: '已取消删除'
  552. });
  553. });
  554. },
  555. globalVisibleChange( val){
  556. // 消息自可见
  557. let msg = {
  558. liveId: this.liveId,
  559. userId: '9999',
  560. userType: 0,
  561. cmd: 'globalVisible',
  562. status:this.globalVisible ? 1 :0
  563. }
  564. this.socket.send(JSON.stringify(msg))
  565. },
  566. changeAutoWatermark( val){
  567. this.watermarkList = this.watermarkTemplate
  568. .split('\n')
  569. .map(line => line.trim())
  570. .filter(line => line !== '');
  571. if(this.watermarkTemplate.length < 1 || this.watermarkList.length === 0) {
  572. this.$message.warning('请先填写水印模板')
  573. this.$nextTick(() => {
  574. this.autoWatermark = false
  575. });
  576. return
  577. }
  578. this.disabled = this.autoWatermark
  579. if(this.autoWatermark){
  580. this.autoMsgTimer = setInterval(() => {
  581. this.sendNormalMessage()
  582. }, 1000 * this.interval)
  583. } else {
  584. clearInterval(this.autoMsgTimer)
  585. this.watermarkIndex = 0;
  586. this.watermarkList = [];
  587. }
  588. },
  589. sendNormalMessage() {
  590. if(this.watermarkIndex >= this.watermarkList.length){
  591. clearInterval(this.autoMsgTimer)
  592. this.watermarkIndex = 0;
  593. this.watermarkList = [];
  594. this.$message.success('自动发送消息已结束');
  595. this.$nextTick(() => {
  596. this.autoWatermark = false
  597. });
  598. return;
  599. }
  600. const curMsg = this.watermarkList[this.watermarkIndex]
  601. // 发送前简单校验
  602. if (curMsg.trim() === '') {
  603. return;
  604. }
  605. let msg = {
  606. msg: curMsg,
  607. liveId: this.liveId,
  608. userId: '9999',
  609. userType: 0,
  610. cmd: 'sendNormalMsg',
  611. avatar: this.$store.state.user.user.avatar,
  612. nickName: this.$store.state.user.user.nickName,
  613. }
  614. this.socket.send(JSON.stringify(msg))
  615. this.watermarkIndex += 1
  616. },
  617. // 弹窗消息
  618. sendPopMessage() {
  619. // 发送前简单校验
  620. if (this.newMsg.trim() === '') {
  621. return;
  622. }
  623. let msg = {
  624. msg: this.newMsg,
  625. liveId: this.liveId,
  626. userId: this.userId,
  627. userType: 1,
  628. cmd: 'sendPopMsg',
  629. avatar: this.$store.state.user.user.avatar,
  630. nickName: this.$store.state.user.user.nickName,
  631. }
  632. this.socket.send(JSON.stringify(msg))
  633. this.newMsg = '';
  634. },
  635. // 显示顶部消息对话框
  636. showTopMsgDialog() {
  637. this.topMsgForm.msg = '';
  638. this.topMsgForm.duration = 5;
  639. this.topMsgDialogVisible = true;
  640. },
  641. // 发送顶部消息
  642. sendTopMessage() {
  643. // 发送前简单校验
  644. if (this.topMsgForm.msg.trim() === '') {
  645. this.$message.warning('请输入消息内容');
  646. return;
  647. }
  648. if (!this.topMsgForm.duration || this.topMsgForm.duration < 1) {
  649. this.$message.warning('请输入有效的显示时长');
  650. return;
  651. }
  652. let msg = {
  653. msg: this.topMsgForm.msg,
  654. duration: this.topMsgForm.duration,
  655. liveId: this.liveId,
  656. userId: this.userId,
  657. userType: 1,
  658. cmd: 'sendTopMsg',
  659. avatar: this.$store.state.user.user.avatar,
  660. nickName: this.$store.state.user.user.nickName,
  661. }
  662. this.socket.send(JSON.stringify(msg))
  663. this.topMsgDialogVisible = false;
  664. this.$message.success('顶部消息发送成功');
  665. },
  666. sendMessage() {
  667. // 发送前简单校验
  668. if (this.newMsg.trim() === '') {
  669. return;
  670. }
  671. let msg = {
  672. msg: this.newMsg,
  673. liveId: this.liveId,
  674. userId: this.userId,
  675. userType: 1,
  676. cmd: 'sendMsg',
  677. avatar: this.$store.state.user.user.avatar,
  678. nickName: this.$store.state.user.user.nickName,
  679. }
  680. this.socket.send(JSON.stringify(msg))
  681. this.newMsg = '';
  682. },
  683. handleWsMessage(event) {
  684. let { code, data } = JSON.parse(event.data)
  685. if (code === 200) {
  686. let { cmd } = data
  687. if (cmd === 'sendMsg') {
  688. let message = JSON.parse(data.data)
  689. let user = this.alDisplayList.find(u => u.userId === message.userId)
  690. if (user) {
  691. message.msgStatus = user.msgStatus
  692. } else {
  693. message.msgStatus = 0
  694. }
  695. delete message.params
  696. if(this.msgList.length > 50){
  697. this.msgList.shift()
  698. }
  699. this.msgList.push(message)
  700. // 如果启用自动滚动,自动滚动到底部
  701. if (this.isAutoScrollEnabled) {
  702. this.$nextTick(() => {
  703. this.autoScrollTimer = setTimeout(() => {
  704. this.scrollToBottom();
  705. }, 200);
  706. });
  707. } else {
  708. // 自动滚动被禁用时,显示加载最新消息按钮
  709. this.showLoadLatestBtn = true;
  710. }
  711. } else if (cmd === 'entry' || cmd === 'out') {
  712. const user = data;
  713. const online = cmd === 'entry' ? 0 : 1; // 0=在线,1=离线
  714. const info = {
  715. online:online,
  716. msgStatus: user.msgStatus || 0,
  717. nickName: user.nickName || '',
  718. userType: user.userType || 0,
  719. userId: user.userId || '',
  720. };
  721. // 1. 更新总人数(在线/离线互转)
  722. if (cmd === 'entry') {
  723. this.userTotal.online += 1;
  724. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1); // 确保不小于0
  725. } else {
  726. this.userTotal.offline += 1;
  727. this.userTotal.online = Math.max(0, this.userTotal.online - 1); // 确保不小于0
  728. }
  729. // 2. 强制更新相关列表(无论当前激活哪个Tab)
  730. if (cmd === 'entry') {
  731. // 用户进入:从离线列表删除,添加到在线列表
  732. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  733. const newOnlineList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  734. newOnlineList.push(info);
  735. this.onlineDisplayList = newOnlineList.slice(-40); // 限制最大50条
  736. } else {
  737. // 用户离开:从在线列表删除,添加到离线列表
  738. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  739. const newOfflineList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  740. newOfflineList.push(info);
  741. this.offlineDisplayList = newOfflineList.slice(-40); // 限制最大50条
  742. }
  743. // 3. 处理禁言列表(如果用户是禁言状态,无论进出都要同步)
  744. if (info.msgStatus === 1) {
  745. // 禁言用户:从禁言列表删除旧数据,添加新数据
  746. const newSilencedList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  747. newSilencedList.push(info);
  748. this.silencedDisplayList = newSilencedList.slice(-40);
  749. } else {
  750. // 非禁言用户:从禁言列表删除(如果存在)
  751. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  752. }
  753. } else if (cmd === 'live_start') {
  754. } else if (cmd === 'live_end') {
  755. }
  756. }
  757. },
  758. getLive(){
  759. getLive(this.liveId).then(res => {
  760. if (res.code == 200) {
  761. if (res.data.isAudit != 1) {
  762. this.$message.error("当前直播间未经审核");
  763. this.loading = false
  764. return
  765. }
  766. this.isAudit = true
  767. this.status = res.data.status
  768. this.videoParam.startTime = new Date(res.data.startTime).getTime()
  769. if(res.data.status == 4){
  770. this.videoParam.liveType = 3
  771. this.videoParam.videoUrl = res.data.videoUrl;
  772. }else {
  773. if (res.data.status != 2) {
  774. this.$message.error("当前直播间未直播");
  775. this.loading = false
  776. return
  777. }
  778. if (res.data.liveType == 1) {
  779. this.videoParam.livingUrl = res.data.flvHlsUrl
  780. this.videoParam.livingUrl = this.videoParam.livingUrl.replace("flv","m3u8")
  781. // this.$nextTick(() => {
  782. // this.initPlayer()
  783. // })
  784. // this.processInterval = setInterval(this.updateLiveProgress, 1000);
  785. } else {
  786. this.videoParam.liveType = 2
  787. this.videoParam.videoUrl = res.data.videoUrl;
  788. // this.$nextTick(() => {
  789. // this.initVideoPlayer(res.data.startTime)
  790. // })
  791. }
  792. }
  793. this.$nextTick(() => {
  794. this.globalVisible = res.data.globalVisible
  795. this.$refs.livePlayer.initPlayer()
  796. })
  797. this.loading = false
  798. } else {
  799. this.$message.error(res.msg)
  800. this.loading = false
  801. }
  802. this.liveInfo = res.data
  803. })
  804. },
  805. connectWebSocket() {
  806. this.$store.dispatch('initLiveWs', {
  807. liveWsUrl: this.liveWsUrl,
  808. liveId: this.liveId,
  809. userId: this.userId
  810. })
  811. this.socket = this.$store.state.liveWs[this.liveId]
  812. this.socket.onmessage = (event) => this.handleWsMessage(event)
  813. },
  814. changeUserState(u) {
  815. const displayList = this[`${this.currentTab}DisplayList`];
  816. // 修改状态
  817. changeUserStatus({liveId: u.liveId, userId: u.userId}).then(response => {
  818. let { code } = response;
  819. if (200 === code) {
  820. u.msgStatus = u.msgStatus === 0 ? 1 : 0
  821. // 同步更新消息列表中相同用户的状态
  822. this.msgList.forEach(msg => {
  823. if (msg.userId === u.userId) {
  824. msg.msgStatus = u.msgStatus;
  825. }
  826. });
  827. displayList.forEach(user => {
  828. if (user.userId === u.userId) {
  829. user.msgStatus = u.msgStatus;
  830. }
  831. });
  832. // 4. 关键:重新筛选所有Tab的显示列表,确保状态同步
  833. this.refreshUserDisplayLists(u);
  834. let msg = u.msgStatus === 0 ? "已解禁" : "已禁言"
  835. this.msgSuccess(msg);
  836. return
  837. }
  838. this.msgError("操作失败");
  839. })
  840. },
  841. refreshUserDisplayLists(user) {
  842. const { userId, msgStatus: newStatus, online } = user;
  843. const oldStatus = newStatus === 1 ? 0 : 1; // 操作前的状态(反向)
  844. // 1. 禁言操作(newStatus=1):从原在线/离线列表移除,加入禁言列表
  845. if (newStatus === 1) {
  846. // 从在线/离线列表中移除该用户(根据当前在线状态)
  847. if (online === 0) {
  848. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== userId);
  849. this.userTotal.online = Math.max(0, this.userTotal.online - 1);
  850. } else {
  851. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== userId);
  852. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1);
  853. }
  854. this.userTotal.silenced = this.userTotal.silenced + 1;
  855. // 添加到禁言列表(去重+限制长度)
  856. const silencedList = this.silencedDisplayList.filter(u => u.userId !== userId);
  857. silencedList.push(user);
  858. this.silencedDisplayList = silencedList.slice(-40);
  859. }
  860. // 2. 解禁操作(newStatus=0):从禁言列表移除,回到原在线/离线列表
  861. else {
  862. // 从禁言列表移除
  863. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== userId);
  864. this.userTotal.silenced = Math.max(0, this.userTotal.silenced - 1);
  865. // 回到对应的在线/离线列表(根据当前在线状态)
  866. if (online === 0) {
  867. // 加入在线列表(去重+限制长度)
  868. const onlineList = this.onlineDisplayList.filter(u => u.userId !== userId);
  869. onlineList.push(user);
  870. this.onlineDisplayList = onlineList.slice(-40);
  871. this.userTotal.online = this.userTotal.online + 1;
  872. } else {
  873. // 加入离线列表(去重+限制长度)
  874. const offlineList = this.offlineDisplayList.filter(u => u.userId !== userId);
  875. offlineList.push(user);
  876. this.offlineDisplayList = offlineList.slice(-40);
  877. this.userTotal.offline = this.userTotal.offline + 1;
  878. }
  879. }
  880. },
  881. blockUser(u){
  882. this.$confirm('是否确认封禁用户账号为:"' + u.nickName + '-' + u.userId + '"?', "警告", {
  883. confirmButtonText: "确定",
  884. cancelButtonText: "取消",
  885. type: "warning"
  886. }).then(function() {
  887. return blockUser(u.userId);
  888. }).then(() => {
  889. if(u.msgStatus === 1){
  890. this.userTotal.silenced -= 1
  891. this.silencedDisplayList = this.silencedDisplayList.filter(user => user.userId !== u.userId)
  892. } else if( u.online === 0){
  893. this.userTotal.online -= 1
  894. this.onlineDisplayList = this.onlineDisplayList.filter(user => user.userId !== u.userId)
  895. } else {
  896. this.userTotal.offline -= 1
  897. this.offlineDisplayList = this.offlineDisplayList.filter(user => user.userId !== u.userId)
  898. }
  899. this.userTotal.al -= 1
  900. this.alDisplayList = this.alDisplayList.filter(user => user.userId !== u.userId)
  901. let msg = {
  902. msg: "",
  903. liveId: this.liveId,
  904. userId: u.userId,
  905. userType: 0,
  906. cmd: 'blockUser',
  907. avatar: this.$store.state.user.user.avatar,
  908. nickName: this.$store.state.user.user.nickName
  909. }
  910. this.socket.send(JSON.stringify(msg))
  911. this.msgSuccess("封禁成功");
  912. }).catch(() => {});
  913. },
  914. searchUsers(){
  915. this.resetUserParams()
  916. this.loadNextPage(this.currentTab)
  917. },
  918. handleUserClick(tab){
  919. const tabName = tab.name;
  920. this.currentTab = tabName;
  921. const params = this.pageParams[tabName];
  922. const displayList = this[`${tabName}DisplayList`];
  923. // 首次切换到该Tab或列表为空时初始化
  924. if (displayList.length < 20) {
  925. // 重置分页参数
  926. params.currentPage = 1;
  927. params.pageSize = 20;
  928. params.prevPage = 0;
  929. params.totalLoaded = 0;
  930. params.hasMore = true;
  931. params.hasPrev = false;
  932. // 加载第一页
  933. this.loadNextPage(tabName);
  934. } else {
  935. // 非首次切换,恢复滚动位置
  936. this.$nextTick(() => {
  937. const scrollEl = this.getScrollElement(tabName);
  938. if (scrollEl) {
  939. scrollEl.scrollTop = this.tabScrollPositions[tabName] || 0;
  940. }
  941. });
  942. }
  943. },
  944. loadNextPage(tabName) {
  945. const params = this.pageParams[tabName];
  946. const displayList = this[`${tabName}DisplayList`];
  947. // 若没有更多数据或正在加载,直接返回
  948. if (!params.hasMore || this.scrLoading[tabName].next) {
  949. return;
  950. }
  951. this.scrLoading[tabName].next = true;
  952. const queryParams = {
  953. liveId: this.liveId,
  954. pageNum: params.currentPage,
  955. pageSize: 20,
  956. online: tabName === 'online' ? 0 : 1,
  957. msgStatus: tabName === 'silenced' ? 1 : 0,
  958. all: tabName === 'al' ? 1 : 0,
  959. userName: this.searchKeyword
  960. };
  961. // 调用接口加载对应状态的分页数据(需后端支持按状态筛选)
  962. dashBoardWatchUserList(queryParams).then(response => {
  963. this.scrLoading[tabName].next = false;
  964. if (response.code !== 200) return;
  965. const { rows, total } = response;
  966. params.total = total; // 记录总数据量
  967. // 过滤重复数据(基于userId)
  968. const newRows = rows.filter(row =>
  969. !displayList.some(u => u.userId === row.userId)
  970. );
  971. displayList.push(...newRows)
  972. // 添加新数据并限制最大长度(避免内存占用过大)
  973. if (displayList.length >= 40) { // 最大保留100条
  974. this[`${tabName}DisplayList`] = displayList.slice(-40);
  975. // 记录滚动位置(用于加载后校准)
  976. const scrollEl = this.getScrollElement(tabName);
  977. // 校准滚动位置(保持视觉连续性)
  978. this.$nextTick(() => {
  979. if (scrollEl) {
  980. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  981. }
  982. });
  983. }
  984. // 更新分页状态
  985. params.hasMore = params.currentPage * params.pageSize < total;
  986. params.currentPage += 1;
  987. params.hasPrev = params.currentPage > 2; // 当前页>2时一定有上一页
  988. params.prevPage = params.currentPage - 2;
  989. }).catch(() => {
  990. this.scrLoading[tabName].next = false;
  991. });
  992. },
  993. // 新增:加载上一页(向上滚动时)
  994. loadPrevPage(tabName) {
  995. const params = this.pageParams[tabName];
  996. const displayList = this[`${tabName}DisplayList`];
  997. // 边界校验:无上一页/正在加载/当前页<=1
  998. console.log(`加载 ${tabName} 上一页`);
  999. console.log(!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1)
  1000. if (!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1) {
  1001. return;
  1002. }
  1003. this.scrLoading[tabName].prev = true;
  1004. const targetPage = params.prevPage > 0 ? params.prevPage : params.currentPage - 2;
  1005. const queryParams = {
  1006. liveId: this.liveId,
  1007. pageNum: targetPage,
  1008. pageSize: 20,
  1009. online: tabName === 'online' ? 0 : 1,
  1010. msgStatus: tabName === 'silenced' ? 1 : 0,
  1011. all: tabName === 'al' ? 1 : 0,
  1012. userName: this.searchKeyword
  1013. };
  1014. dashBoardWatchUserList(queryParams).then(response => {
  1015. this.scrLoading[tabName].prev = false;
  1016. if (response.code !== 200) return;
  1017. const { rows } = response;
  1018. if (rows.length === 0) {
  1019. params.hasPrev = false;
  1020. return;
  1021. }
  1022. // 记录滚动位置(用于加载后校准)
  1023. const scrollEl = this.getScrollElement(tabName);
  1024. const scrollTop = scrollEl?.scrollTop || 0;
  1025. const itemHeight = 80; // 预估行高(根据实际样式调整)
  1026. const newItemsHeight = rows.length * itemHeight;
  1027. // 过滤重复数据并添加到列表头部
  1028. const newRows = rows.filter(row => !displayList.some(u => u.userId === row.userId));
  1029. this[`${tabName}DisplayList`] = [...newRows, ...displayList];
  1030. params.totalLoaded += newRows.length;
  1031. // 限制最大长度
  1032. if (this[`${tabName}DisplayList`].length > 40) {
  1033. this[`${tabName}DisplayList`] = this[`${tabName}DisplayList`].slice(0, 40);
  1034. }
  1035. // 更新分页状态
  1036. params.prevPage = targetPage - 1;
  1037. params.hasPrev = targetPage > 1; // 上一页页码>1时还有更多上一页
  1038. params.currentPage = params.currentPage - 1;
  1039. if(params.currentPage * 20 < params.total) params.hasMore = true;
  1040. // 校准滚动位置(保持视觉连续性)
  1041. this.$nextTick(() => {
  1042. if (scrollEl) {
  1043. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  1044. }
  1045. });
  1046. }).catch(() => {
  1047. this.scrLoading[tabName].prev = false;
  1048. });
  1049. },
  1050. getList() {
  1051. this.resetUserParams()
  1052. this.resetMsgParams()
  1053. // this.loadUserList()
  1054. this.loadUserTotals(); // 先加载总人数
  1055. // this.handleClick('online')
  1056. // this.handleClick({name:'online'})
  1057. this.loadMsgList()
  1058. this.loadLiveTask()
  1059. },
  1060. formatDate(value) {
  1061. // 检查值是否存在且为日期类型(或可转换为日期)
  1062. if (!value) return '';
  1063. let date;
  1064. // 处理字符串类型的日期
  1065. if (typeof value === 'string') {
  1066. date = new Date(value);
  1067. }
  1068. // 处理Date对象
  1069. else if (value instanceof Date) {
  1070. date = value;
  1071. }
  1072. // 无效类型直接返回原值
  1073. else {
  1074. return value;
  1075. }
  1076. // 检查是否为有效日期
  1077. if (isNaN(date.getTime())) {
  1078. return value;
  1079. }
  1080. // 格式化年月日时分秒(补零处理)
  1081. const year = date.getFullYear();
  1082. const month = String(date.getMonth() + 1).padStart(2, '0');
  1083. const day = String(date.getDate()).padStart(2, '0');
  1084. const hours = String(date.getHours()).padStart(2, '0');
  1085. const minutes = String(date.getMinutes()).padStart(2, '0');
  1086. const seconds = String(date.getSeconds()).padStart(2, '0');
  1087. return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  1088. },
  1089. loadLiveTask(){
  1090. if(!this.taskParams.hasMore) return
  1091. const queryParams = {
  1092. liveId: this.liveId,
  1093. pageSize: 10,
  1094. pageNum: 1
  1095. }
  1096. consoleList(queryParams).then(res => {
  1097. let {code, rows,total} = res;
  1098. if (code === 200) {
  1099. this.taskParams.total = total
  1100. this.timelineItems = rows
  1101. if(rows.length < this.taskParams.pageSize){
  1102. this.taskParams.hasMore = false
  1103. }
  1104. this.startTaskTimer()
  1105. } else {
  1106. this.stopTaskTimer()
  1107. }
  1108. })
  1109. },
  1110. loadMsgList(){
  1111. // 直播间消息
  1112. listLiveSingleMsg({
  1113. liveId:this.liveId,
  1114. pageNum: this.msgParams.pageNum,
  1115. pageSize: this.msgParams.pageSize
  1116. }).then(response => {
  1117. let {code, rows,total} = response;
  1118. if (code === 200) {
  1119. let totalPage = (total % this.msgParams.pageSize == 0) ? Math.floor(total / this.msgParams.pageSize) : Math.floor(total / this.msgParams.pageSize + 1);
  1120. rows.forEach(row => {
  1121. if (!this.msgList.some(m => m.msgId === row.msgId)) {
  1122. let user = this.alDisplayList.find(u => u.userId === row.userId)
  1123. if (user) {
  1124. row.msgStatus = user.msgStatus
  1125. } else {
  1126. row.msgStatus = 0
  1127. }
  1128. this.msgList.push(row)
  1129. }
  1130. })
  1131. this.msgList.reverse()
  1132. // 同步更新消息列表中相同用户的状态
  1133. this.alDisplayList.forEach(u => {
  1134. this.msgList.filter(m => m.userId === u.userId).forEach(m => m.msgStatus = u.msgStatus)
  1135. })
  1136. // 所有消息加载完成后,根据自动滚动状态决定是否滚动
  1137. this.$nextTick(() => {
  1138. setTimeout(() => {
  1139. // 重置加载状态
  1140. this.isLoadingLatest = false;
  1141. if (this.isAutoScrollEnabled) {
  1142. // 如果启用自动滚动,强制滚动到底部并隐藏按钮
  1143. this.scrollToBottom(true);
  1144. } else {
  1145. // 如果禁用自动滚动,检查是否在底部,决定是否显示按钮
  1146. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1147. const wrap = this.$refs.manageRightRef.wrap;
  1148. const scrollHeight = wrap.scrollHeight;
  1149. const clientHeight = wrap.clientHeight;
  1150. const currentScrollTop = wrap.scrollTop;
  1151. const maxScrollTop = scrollHeight - clientHeight;
  1152. if (currentScrollTop < maxScrollTop - 50) {
  1153. this.showLoadLatestBtn = true;
  1154. } else {
  1155. this.showLoadLatestBtn = false;
  1156. }
  1157. }
  1158. }
  1159. }, 300);
  1160. });
  1161. }
  1162. })
  1163. // 添加滚动监听
  1164. this.$nextTick(() => {
  1165. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1166. this.$refs.manageRightRef.wrap.addEventListener("scroll", this.manageRightScroll)
  1167. }
  1168. })
  1169. },
  1170. // 消息滚动监听(带防抖)
  1171. manageRightScroll() {
  1172. // 清除之前的防抖定时器
  1173. if (this.scrollDebounceTimer) {
  1174. clearTimeout(this.scrollDebounceTimer);
  1175. }
  1176. // 设置防抖,300ms内只执行一次
  1177. this.scrollDebounceTimer = setTimeout(() => {
  1178. this.saveChatScrollPosition();
  1179. // 检查是否滚动到底部
  1180. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1181. const wrap = this.$refs.manageRightRef.wrap;
  1182. const scrollHeight = wrap.scrollHeight;
  1183. const clientHeight = wrap.clientHeight;
  1184. const currentScrollTop = wrap.scrollTop;
  1185. const maxScrollTop = scrollHeight - clientHeight;
  1186. // 如果滚动到底部(距离底部小于50px),隐藏按钮并恢复自动滚动
  1187. if (currentScrollTop >= maxScrollTop - 50) {
  1188. this.showLoadLatestBtn = false;
  1189. this.isAutoScrollEnabled = true;
  1190. }
  1191. }
  1192. }, 300);
  1193. },
  1194. loadUserTotals() {
  1195. if (!this.liveId) return;
  1196. // 假设后端提供一个接口返回总人数(如果没有,可通过首次加载全量数据后统计)
  1197. getLiveUserTotals({ liveId: this.liveId }).then(res => {
  1198. if (res.code === 200) {
  1199. this.userTotal = res.data; // { online, offline, silenced }
  1200. }
  1201. });
  1202. },
  1203. toggleBlack(user) {
  1204. user.isBlack = !user.isBlack;
  1205. },
  1206. toggleMute(user) {
  1207. user.isMuted = !user.isMuted;
  1208. },
  1209. toggleVisible(msg) {
  1210. msg.isVisible = !msg.isVisible;
  1211. },
  1212. deleteMessage(msg) {
  1213. const index = this.messages.indexOf(msg);
  1214. if (index > -1) {
  1215. this.messages.splice(index, 1);
  1216. }
  1217. },
  1218. addTimelineItem() {
  1219. this.timelineItems.push({ time: '00:00', action: '新动作' });
  1220. },
  1221. removeTimelineItem(item) {
  1222. const index = this.timelineItems.indexOf(item);
  1223. if (index > -1) {
  1224. this.timelineItems.splice(index, 1);
  1225. }
  1226. },
  1227. resetUserParams() {
  1228. // 重置各Tab的显示列表和分页参数
  1229. this.alDisplayList = [];
  1230. this.onlineDisplayList = []; // 在线用户显示列表
  1231. this.offlineDisplayList = []; // 离线用户显示列表
  1232. this.silencedDisplayList = []; // 禁言用户显示列表
  1233. this.pageParams= {
  1234. al: {
  1235. currentPage: 1,
  1236. pageSize: 20,
  1237. prevPage: 0,
  1238. totalLoaded: 0,
  1239. total: 0,
  1240. hasMore: true,
  1241. hasPrev: false
  1242. },
  1243. online: {
  1244. currentPage: 1, // 当前页(下一页加载用)
  1245. pageSize: 20, // 当前页(下一页加载用)
  1246. prevPage: 0, // 上一页页码(上一页加载用)
  1247. totalLoaded: 0, // 已加载总条数
  1248. total: 0, // 总数据量
  1249. hasMore: true, // 是否有下一页
  1250. hasPrev: false // 是否有上一页
  1251. },
  1252. offline: {
  1253. currentPage: 1,
  1254. pageSize: 20,
  1255. prevPage: 0,
  1256. totalLoaded: 0,
  1257. total: 0,
  1258. hasMore: true,
  1259. hasPrev: false
  1260. },
  1261. silenced: {
  1262. currentPage: 1,
  1263. pageSize: 20,
  1264. prevPage: 0,
  1265. totalLoaded: 0,
  1266. total: 0,
  1267. hasMore: true,
  1268. hasPrev: false
  1269. }
  1270. },
  1271. this.scrLoading = {
  1272. al: { next: false, prev: false },
  1273. online: { next: false, prev: false },
  1274. offline: { next: false, prev: false },
  1275. silenced: { next: false, prev: false }
  1276. }
  1277. },
  1278. resetMsgParams() {
  1279. // 消息参数保留
  1280. this.msgList = [];
  1281. this.msgParams = {
  1282. pageNum: 1,
  1283. pageSize: 30,
  1284. liveId: this.liveId
  1285. };
  1286. // 重置时不改变按钮状态,由后续的滚动逻辑决定
  1287. this.taskParams = {
  1288. currentPage: 1,
  1289. pageSize: 20,
  1290. prevPage: 0,
  1291. totalLoaded: 0,
  1292. total: 0,
  1293. hasMore: true,
  1294. hasPrev: false
  1295. }
  1296. },
  1297. getScrollElement(tabName) {
  1298. const scrollRefs = {
  1299. al: this.$refs.manageLeftRef_al,
  1300. online: this.$refs.manageLeftRef_online,
  1301. offline: this.$refs.manageLeftRef_offline,
  1302. silenced: this.$refs.manageLeftRef_silenced
  1303. };
  1304. return scrollRefs[tabName]?.wrap;
  1305. },
  1306. // 初始化滚动监听(在mounted中调用)
  1307. initScrollListeners() {
  1308. // 为每个Tab的滚动容器添加监听
  1309. this.$nextTick(() => {
  1310. const scrollRefs = {
  1311. al: this.$refs.manageLeftRef_al,
  1312. online: this.$refs.manageLeftRef_online,
  1313. offline: this.$refs.manageLeftRef_offline,
  1314. silenced: this.$refs.manageLeftRef_silenced
  1315. };
  1316. Object.keys(scrollRefs).forEach(tabName => {
  1317. const scrollEl = scrollRefs[tabName]?.wrap;
  1318. if (scrollEl) {
  1319. scrollEl.addEventListener('scroll', () =>
  1320. this.handleTabScroll(tabName, scrollEl)
  1321. );
  1322. }
  1323. });
  1324. });
  1325. },
  1326. // 处理Tab滚动事件(判断是否触底)
  1327. handleTabScroll(tabName, scrollEl) {
  1328. const { scrollTop, scrollHeight, clientHeight } = scrollEl;
  1329. const bottomThreshold = 50; // 距离底部100px触发下一页
  1330. const topThreshold = 50; // 距离顶部100px触发上一页
  1331. // 加载下一页(滚动到底部附近)
  1332. if (scrollHeight - scrollTop - clientHeight < bottomThreshold) {
  1333. this.loadNextPage(tabName);
  1334. }
  1335. // 加载上一页(滚动到顶部附近)
  1336. if (scrollTop < topThreshold) {
  1337. this.loadPrevPage(tabName);
  1338. }
  1339. },
  1340. // 恢复聊天滚动位置
  1341. restoreChatScrollPosition() {
  1342. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1343. this.$refs.manageRightRef.wrap.scrollTop = this.chatScrollTop;
  1344. }
  1345. },
  1346. // 保存聊天滚动位置
  1347. saveChatScrollPosition() {
  1348. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1349. this.chatScrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight;
  1350. }
  1351. },
  1352. /**
  1353. * 停止任务检测定时器
  1354. */
  1355. stopTaskTimer() {
  1356. if (this.taskTimer) {
  1357. clearInterval(this.taskTimer);
  1358. this.taskTimer = null;
  1359. }
  1360. },
  1361. /**
  1362. * 启动任务检测定时器
  1363. */
  1364. startTaskTimer() {
  1365. // 先清除已有定时器,避免重复
  1366. if (this.taskTimer) {
  1367. clearInterval(this.taskTimer);
  1368. }
  1369. // 立即执行一次检查
  1370. this.checkTaskExpiration();
  1371. // 启动定时器,定期检查
  1372. this.taskTimer = setInterval(() => {
  1373. this.checkTaskExpiration();
  1374. }, this.checkInterval);
  1375. },
  1376. /**
  1377. * 检查时间轴第一条任务是否过期
  1378. */
  1379. checkTaskExpiration() {
  1380. // 如果没有任务,直接返回
  1381. if (!this.timelineItems || this.timelineItems.length === 0) {
  1382. this.stopTaskTimer()
  1383. return;
  1384. };
  1385. // 获取第一条任务的时间
  1386. const firstTask = this.timelineItems[0];
  1387. const taskTime = new Date(firstTask.absValue).getTime();
  1388. const currentTime = new Date().getTime();
  1389. // 如果任务时间已过当前时间(过期),重新加载任务列表
  1390. if (taskTime < currentTime) {
  1391. this.timelineItems.shift()
  1392. this.loadLiveTask(); // 重新加载任务列表
  1393. }
  1394. },
  1395. },
  1396. beforeDestroy() {
  1397. if (this.autoMsgTimer != null) {
  1398. clearInterval(this.autoMsgTimer);
  1399. }
  1400. if (this.autoScrollTimer) {
  1401. clearTimeout(this.autoScrollTimer);
  1402. }
  1403. if (this.scrollDebounceTimer) {
  1404. clearTimeout(this.scrollDebounceTimer);
  1405. }
  1406. // 移除滚动监听器
  1407. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1408. this.$refs.manageRightRef.wrap.removeEventListener("scroll", this.manageRightScroll);
  1409. }
  1410. },
  1411. // 使用 deactivated 和 activated 钩子替代 beforeDestroy 和 destroyed
  1412. deactivated() {
  1413. this.saveChatScrollPosition();
  1414. },
  1415. activated() {
  1416. this.$nextTick(() => {
  1417. this.restoreChatScrollPosition();
  1418. });
  1419. // todo yhq
  1420. // this.$nextTick(() => {
  1421. // const video = this.$refs.videoPlayer;
  1422. // if (video != null) {
  1423. // this.initVideoPlayer(this.liveInfo.startTime)
  1424. // }
  1425. // })
  1426. },
  1427. };
  1428. </script>
  1429. <style scoped>
  1430. .console {
  1431. display: flex;
  1432. height: 100vh;
  1433. }
  1434. .left-panel, .middle-panel, .right-panel {
  1435. padding: 20px;
  1436. box-sizing: border-box;
  1437. }
  1438. .left-panel {
  1439. width: 30%;
  1440. background: #f8fafc;
  1441. border-right: 1px solid #e2e8f0;
  1442. }
  1443. .middle-panel {
  1444. width: 40%;
  1445. background: #f8fafc;
  1446. border-right: 1px solid #e2e8f0;
  1447. }
  1448. .right-panel {
  1449. width: 30%;
  1450. background: #f8fafc;
  1451. }
  1452. .search {
  1453. margin: 10px 0;
  1454. }
  1455. .search input {
  1456. width: 70%;
  1457. padding: 8px;
  1458. border: 1px solid #cbd5e1;
  1459. border-radius: 4px;
  1460. }
  1461. .search button {
  1462. padding: 8px 15px;
  1463. background: #3b82f6;
  1464. color: #fff;
  1465. border: none;
  1466. border-radius: 4px;
  1467. cursor: pointer;
  1468. }
  1469. .tabs {
  1470. display: flex;
  1471. margin: 10px 0;
  1472. }
  1473. .tabs button {
  1474. padding: 8px 15px;
  1475. border: 1px solid #e2e8f0;
  1476. background: #fff;
  1477. cursor: pointer;
  1478. }
  1479. .tabs button.active {
  1480. background: #3b82f6;
  1481. color: #fff;
  1482. border-color: #3b82f6;
  1483. }
  1484. .user-list {
  1485. max-height: 600px;
  1486. overflow-y: auto;
  1487. }
  1488. .user-item {
  1489. display: flex;
  1490. align-items: center;
  1491. padding: 10px;
  1492. border-bottom: 1px solid #e2e8f0;
  1493. }
  1494. .user-item img {
  1495. width: 40px;
  1496. height: 40px;
  1497. border-radius: 50%;
  1498. margin-right: 10px;
  1499. }
  1500. .user-info {
  1501. flex: 1;
  1502. }
  1503. .user-name {
  1504. font-weight: bold;
  1505. }
  1506. .user-status {
  1507. font-size: 12px;
  1508. color: #64748b;
  1509. }
  1510. .online {
  1511. color: #10b981;
  1512. }
  1513. .offline {
  1514. color: #94a3b8;
  1515. }
  1516. .user-actions {
  1517. display: flex;
  1518. }
  1519. .user-actions button {
  1520. padding: 5px 10px;
  1521. margin-left: 5px;
  1522. border: none;
  1523. border-radius: 4px;
  1524. cursor: pointer;
  1525. }
  1526. .block {
  1527. background: #ef4444;
  1528. color: #fff;
  1529. }
  1530. .unblock {
  1531. background: #10b981;
  1532. color: #fff;
  1533. }
  1534. .mute {
  1535. background: #f59e0b;
  1536. color: #fff;
  1537. }
  1538. .unmute {
  1539. background: #3b82f6;
  1540. color: #fff;
  1541. }
  1542. .system-messages, .discussion-messages {
  1543. margin: 20px 0;
  1544. background: #fff;
  1545. padding: 15px;
  1546. border-radius: 8px;
  1547. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1548. }
  1549. .system-messages textarea {
  1550. width: 100%;
  1551. height: 100px;
  1552. border: 1px solid #e2e8f0;
  1553. border-radius: 4px;
  1554. padding: 8px;
  1555. box-sizing: border-box;
  1556. }
  1557. .message-actions {
  1558. margin-top: 10px;
  1559. }
  1560. .message-actions button {
  1561. padding: 5px 10px;
  1562. margin-right: 5px;
  1563. background: #3b82f6;
  1564. color: #fff;
  1565. border: none;
  1566. border-radius: 4px;
  1567. cursor: pointer;
  1568. }
  1569. .message-list {
  1570. max-height: 300px;
  1571. overflow-y: auto;
  1572. margin-top: 10px;
  1573. }
  1574. .message-item {
  1575. display: flex;
  1576. margin-bottom: 10px;
  1577. padding-bottom: 10px;
  1578. border-bottom: 1px solid #e2e8f0;
  1579. }
  1580. .message-avatar img {
  1581. width: 30px;
  1582. height: 30px;
  1583. border-radius: 50%;
  1584. margin-right: 10px;
  1585. }
  1586. .message-content {
  1587. flex: 1;
  1588. }
  1589. .message-user {
  1590. font-weight: bold;
  1591. }
  1592. .message-text {
  1593. font-size: 14px;
  1594. color: #64748b;
  1595. }
  1596. .message-actions button {
  1597. padding: 3px 8px;
  1598. font-size: 12px;
  1599. background: #3b82f6;
  1600. color: #fff;
  1601. border: none;
  1602. border-radius: 4px;
  1603. cursor: pointer;
  1604. }
  1605. .live-player, .automation, .watermark {
  1606. margin: 20px 0;
  1607. background: #fff;
  1608. padding: 15px;
  1609. border-radius: 8px;
  1610. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1611. }
  1612. .timeline-items {
  1613. margin: 10px 0;
  1614. }
  1615. .timeline-item {
  1616. display: flex;
  1617. justify-content: space-between;
  1618. align-items: center;
  1619. padding: 8px 0;
  1620. border-bottom: 1px solid #e2e8f0;
  1621. }
  1622. .delete {
  1623. background: #ef4444;
  1624. color: #fff;
  1625. border: none;
  1626. border-radius: 4px;
  1627. padding: 3px 8px;
  1628. cursor: pointer;
  1629. }
  1630. .add {
  1631. background: #10b981;
  1632. color: #fff;
  1633. border: none;
  1634. border-radius: 4px;
  1635. padding: 8px 15px;
  1636. cursor: pointer;
  1637. }
  1638. .watermark-settings textarea {
  1639. width: 100%;
  1640. height: 100px;
  1641. border: 1px solid #e2e8f0;
  1642. border-radius: 4px;
  1643. padding: 8px;
  1644. box-sizing: border-box;
  1645. }
  1646. .watermark-options {
  1647. margin-top: 10px;
  1648. }
  1649. .watermark-options label {
  1650. display: block;
  1651. margin-bottom: 5px;
  1652. }
  1653. /* 隐藏 el-scrollbar 的横向滚动条 */
  1654. .el-scrollbar__wrap {
  1655. overflow-x: hidden !important;
  1656. }
  1657. .custom-scrollbar .el-scrollbar__wrap {
  1658. overflow-x: hidden !important;
  1659. }
  1660. .scrollbar-demo-item{
  1661. display: flex;
  1662. align-items: center;
  1663. justify-content: center;
  1664. height: 50px;
  1665. margin: 10px;
  1666. text-align: center;
  1667. border-radius: 4px;
  1668. }
  1669. .message-container {
  1670. position: relative;
  1671. }
  1672. .load-latest-btn {
  1673. position: absolute;
  1674. bottom: 20px;
  1675. right: 20px;
  1676. z-index: 10;
  1677. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  1678. }
  1679. </style>