LiveConsole.vue 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785
  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" style="margin-top: 5px" type="flex" align="top" >
  114. <el-col :span="3" style="margin-left: 10px"><el-avatar :src="m.avatar"/></el-col>
  115. <el-col :span="15">
  116. <el-row style="margin-left: 10px">
  117. <el-col><div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div></el-col>
  118. <el-col :span="24" style="max-width: 200px;">
  119. <div style="white-space: normal; word-wrap: break-word;background-color: #f0f2f5; padding: 8px; border-radius: 5px;font-size: 14px;width: 100%;">
  120. {{ m.msg }}
  121. </div>
  122. </el-col>
  123. <el-col>
  124. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="changeUserState(m)">{{ m.msgStatus === 1 ? '解禁' : '禁言' }}</a>
  125. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="blockUser(m)">拉黑</a>
  126. <a v-if="m.singleVisible === 1" style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">解除用户自见</a>
  127. <a v-else style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">用户自见</a>
  128. <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="deleteMsg(m)">删除</a>
  129. </el-col>
  130. </el-row>
  131. </el-col>
  132. </el-row>
  133. <el-row v-if="m.userId === userId" style="padding: 8px 0" type="flex" align="top" justify="end">
  134. <div style="display: flex;justify-content: flex-end">
  135. <div style="display: flex;justify-content: flex-end;flex-direction: column;max-width: 200px;align-items: flex-end">
  136. <div style="font-size: 12px; color: #999; margin-bottom: 3px;">{{ m.nickName }}</div>
  137. <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>
  138. </div>
  139. <el-avatar :src="m.avatar" style="margin-left: 10px; margin-right: 10px;"/>
  140. </div>
  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. // 强制滚动或启用自动滚动时,直接滚动到底部并隐藏按钮
  479. if (forceScroll || this.isAutoScrollEnabled) {
  480. this.showLoadLatestBtn = false;
  481. this.$refs.manageRightRef.wrap.scrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight
  482. }
  483. });
  484. }
  485. },
  486. // 加载最新消息
  487. loadLatestMessages() {
  488. // 如果正在加载,直接返回
  489. if (this.isLoadingLatest) {
  490. return;
  491. }
  492. this.isLoadingLatest = true;
  493. this.showLoadLatestBtn = false;
  494. // 恢复自动滚动
  495. this.isAutoScrollEnabled = true;
  496. // 重新请求最新消息
  497. this.resetMsgParams();
  498. // loadMsgList 中会自动滚动到底部,因为 isAutoScrollEnabled 已经是 true
  499. this.loadMsgList();
  500. },
  501. singleVisible(m){
  502. // 过滤当前所有消息 找到userId的相同的消息 更改他们的自可见状态
  503. m.singleVisible= m.singleVisible === 1 ? 0 : 1
  504. this.msgList.forEach(m1 => {m1.singleVisible = m1.userId === m.userId ? m.singleVisible : !m.singleVisible})
  505. // 消息自可见
  506. let msg = {
  507. liveId: this.liveId,
  508. userId: m.userId,
  509. userType: 0,
  510. cmd: 'singleVisible',
  511. status:m.singleVisible
  512. }
  513. this.socket.send(JSON.stringify(msg))
  514. },
  515. deleteMsg(m){
  516. // 1. 弹出确认对话框
  517. this.$confirm('此操作将永久删除该消息, 是否继续?', '提示', {
  518. confirmButtonText: '确定',
  519. cancelButtonText: '取消',
  520. type: 'warning'
  521. }).then(() => {
  522. delLiveMsg(m.msgId).then(res => {
  523. if (res.code === 200) {
  524. const index = this.msgList.findIndex(item => item.msgId === m.msgId);
  525. if (index !== -1) {
  526. this.msgList.splice(index, 1);
  527. console.log(`消息 ${m.msgId} 已在本地删除。`);
  528. }
  529. let msg = {
  530. liveId: this.liveId,
  531. userId: m.userId,
  532. msg: m.msgId, // 关键:将消息ID发送给后台
  533. cmd: 'deleteMsg',
  534. };
  535. this.socket.send(JSON.stringify(msg));
  536. // 可以在这里给用户一个删除成功的提示
  537. this.$message({
  538. type: 'success',
  539. message: '消息删除成功!'
  540. });
  541. }
  542. })
  543. }).catch(() => {
  544. // 3. 用户点击“取消”或关闭对话框后的回调
  545. this.$message({
  546. type: 'info',
  547. message: '已取消删除'
  548. });
  549. });
  550. },
  551. globalVisibleChange( val){
  552. // 消息自可见
  553. let msg = {
  554. liveId: this.liveId,
  555. userId: '9999',
  556. userType: 0,
  557. cmd: 'globalVisible',
  558. status:this.globalVisible ? 1 :0
  559. }
  560. this.socket.send(JSON.stringify(msg))
  561. },
  562. changeAutoWatermark( val){
  563. this.watermarkList = this.watermarkTemplate
  564. .split('\n')
  565. .map(line => line.trim())
  566. .filter(line => line !== '');
  567. if(this.watermarkTemplate.length < 1 || this.watermarkList.length === 0) {
  568. this.$message.warning('请先填写水印模板')
  569. this.$nextTick(() => {
  570. this.autoWatermark = false
  571. });
  572. return
  573. }
  574. this.disabled = this.autoWatermark
  575. if(this.autoWatermark){
  576. this.autoMsgTimer = setInterval(() => {
  577. this.sendNormalMessage()
  578. }, 1000 * this.interval)
  579. } else {
  580. clearInterval(this.autoMsgTimer)
  581. this.watermarkIndex = 0;
  582. this.watermarkList = [];
  583. }
  584. },
  585. sendNormalMessage() {
  586. if(this.watermarkIndex >= this.watermarkList.length){
  587. clearInterval(this.autoMsgTimer)
  588. this.watermarkIndex = 0;
  589. this.watermarkList = [];
  590. this.$message.success('自动发送消息已结束');
  591. this.$nextTick(() => {
  592. this.autoWatermark = false
  593. });
  594. return;
  595. }
  596. const curMsg = this.watermarkList[this.watermarkIndex]
  597. // 发送前简单校验
  598. if (curMsg.trim() === '') {
  599. return;
  600. }
  601. let msg = {
  602. msg: curMsg,
  603. liveId: this.liveId,
  604. userId: '9999',
  605. userType: 0,
  606. cmd: 'sendNormalMsg',
  607. avatar: this.$store.state.user.user.avatar,
  608. nickName: this.$store.state.user.user.nickName,
  609. }
  610. this.socket.send(JSON.stringify(msg))
  611. this.watermarkIndex += 1
  612. },
  613. // 弹窗消息
  614. sendPopMessage() {
  615. // 发送前简单校验
  616. if (this.newMsg.trim() === '') {
  617. return;
  618. }
  619. let msg = {
  620. msg: this.newMsg,
  621. liveId: this.liveId,
  622. userId: this.userId,
  623. userType: 1,
  624. cmd: 'sendPopMsg',
  625. avatar: this.$store.state.user.user.avatar,
  626. nickName: this.$store.state.user.user.nickName,
  627. }
  628. this.socket.send(JSON.stringify(msg))
  629. this.newMsg = '';
  630. },
  631. // 显示顶部消息对话框
  632. showTopMsgDialog() {
  633. this.topMsgForm.msg = '';
  634. this.topMsgForm.duration = 5;
  635. this.topMsgDialogVisible = true;
  636. },
  637. // 发送顶部消息
  638. sendTopMessage() {
  639. // 发送前简单校验
  640. if (this.topMsgForm.msg.trim() === '') {
  641. this.$message.warning('请输入消息内容');
  642. return;
  643. }
  644. if (!this.topMsgForm.duration || this.topMsgForm.duration < 1) {
  645. this.$message.warning('请输入有效的显示时长');
  646. return;
  647. }
  648. let msg = {
  649. msg: this.topMsgForm.msg,
  650. duration: this.topMsgForm.duration,
  651. liveId: this.liveId,
  652. userId: this.userId,
  653. userType: 1,
  654. cmd: 'sendTopMsg',
  655. avatar: this.$store.state.user.user.avatar,
  656. nickName: this.$store.state.user.user.nickName,
  657. }
  658. this.socket.send(JSON.stringify(msg))
  659. this.topMsgDialogVisible = false;
  660. this.$message.success('顶部消息发送成功');
  661. },
  662. sendMessage() {
  663. // 发送前简单校验
  664. if (this.newMsg.trim() === '') {
  665. return;
  666. }
  667. let msg = {
  668. msg: this.newMsg,
  669. liveId: this.liveId,
  670. userId: this.userId,
  671. userType: 1,
  672. cmd: 'sendMsg',
  673. avatar: this.$store.state.user.user.avatar,
  674. nickName: this.$store.state.user.user.nickName,
  675. }
  676. this.socket.send(JSON.stringify(msg))
  677. this.newMsg = '';
  678. },
  679. handleWsMessage(event) {
  680. let { code, data } = JSON.parse(event.data)
  681. if (code === 200) {
  682. let { cmd } = data
  683. if (cmd === 'sendMsg') {
  684. let message = JSON.parse(data.data)
  685. let user = this.alDisplayList.find(u => u.userId === message.userId)
  686. if (user) {
  687. message.msgStatus = user.msgStatus
  688. } else {
  689. message.msgStatus = 0
  690. }
  691. delete message.params
  692. if(this.msgList.length > 50){
  693. this.msgList.shift()
  694. }
  695. this.msgList.push(message)
  696. // 如果启用自动滚动,自动滚动到底部
  697. if (this.isAutoScrollEnabled) {
  698. this.$nextTick(() => {
  699. this.autoScrollTimer = setTimeout(() => {
  700. this.scrollToBottom();
  701. }, 200);
  702. });
  703. } else {
  704. // 自动滚动被禁用时,显示加载最新消息按钮
  705. this.showLoadLatestBtn = true;
  706. }
  707. } else if (cmd === 'entry' || cmd === 'out') {
  708. const user = data;
  709. const online = cmd === 'entry' ? 0 : 1; // 0=在线,1=离线
  710. const info = {
  711. online:online,
  712. msgStatus: user.msgStatus || 0,
  713. nickName: user.nickName || '',
  714. userType: user.userType || 0,
  715. userId: user.userId || '',
  716. };
  717. // 1. 更新总人数(在线/离线互转)
  718. if (cmd === 'entry') {
  719. this.userTotal.online += 1;
  720. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1); // 确保不小于0
  721. } else {
  722. this.userTotal.offline += 1;
  723. this.userTotal.online = Math.max(0, this.userTotal.online - 1); // 确保不小于0
  724. }
  725. // 2. 强制更新相关列表(无论当前激活哪个Tab)
  726. if (cmd === 'entry') {
  727. // 用户进入:从离线列表删除,添加到在线列表
  728. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  729. const newOnlineList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  730. newOnlineList.push(info);
  731. this.onlineDisplayList = newOnlineList.slice(-40); // 限制最大50条
  732. } else {
  733. // 用户离开:从在线列表删除,添加到离线列表
  734. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== user.userId);
  735. const newOfflineList = this.offlineDisplayList.filter(u => u.userId !== user.userId);
  736. newOfflineList.push(info);
  737. this.offlineDisplayList = newOfflineList.slice(-40); // 限制最大50条
  738. }
  739. // 3. 处理禁言列表(如果用户是禁言状态,无论进出都要同步)
  740. if (info.msgStatus === 1) {
  741. // 禁言用户:从禁言列表删除旧数据,添加新数据
  742. const newSilencedList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  743. newSilencedList.push(info);
  744. this.silencedDisplayList = newSilencedList.slice(-40);
  745. } else {
  746. // 非禁言用户:从禁言列表删除(如果存在)
  747. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
  748. }
  749. } else if (cmd === 'live_start') {
  750. } else if (cmd === 'live_end') {
  751. }
  752. }
  753. },
  754. getLive(){
  755. getLive(this.liveId).then(res => {
  756. if (res.code == 200) {
  757. if (res.data.isAudit != 1) {
  758. this.$message.error("当前直播间未经审核");
  759. this.loading = false
  760. return
  761. }
  762. this.isAudit = true
  763. this.status = res.data.status
  764. this.videoParam.startTime = new Date(res.data.startTime).getTime()
  765. if(res.data.status == 4){
  766. this.videoParam.liveType = 3
  767. this.videoParam.videoUrl = res.data.videoUrl;
  768. }else {
  769. if (res.data.status != 2) {
  770. this.$message.error("当前直播间未直播");
  771. this.loading = false
  772. return
  773. }
  774. if (res.data.liveType == 1) {
  775. this.videoParam.livingUrl = res.data.flvHlsUrl
  776. this.videoParam.livingUrl = this.videoParam.livingUrl.replace("flv","m3u8")
  777. // this.$nextTick(() => {
  778. // this.initPlayer()
  779. // })
  780. // this.processInterval = setInterval(this.updateLiveProgress, 1000);
  781. } else {
  782. this.videoParam.liveType = 2
  783. this.videoParam.videoUrl = res.data.videoUrl;
  784. // this.$nextTick(() => {
  785. // this.initVideoPlayer(res.data.startTime)
  786. // })
  787. }
  788. }
  789. this.$nextTick(() => {
  790. this.globalVisible = res.data.globalVisible
  791. this.$refs.livePlayer.initPlayer()
  792. })
  793. this.loading = false
  794. } else {
  795. this.$message.error(res.msg)
  796. this.loading = false
  797. }
  798. this.liveInfo = res.data
  799. })
  800. },
  801. connectWebSocket() {
  802. this.$store.dispatch('initLiveWs', {
  803. liveWsUrl: this.liveWsUrl,
  804. liveId: this.liveId,
  805. userId: this.userId
  806. })
  807. this.socket = this.$store.state.liveWs[this.liveId]
  808. this.socket.onmessage = (event) => this.handleWsMessage(event)
  809. },
  810. changeUserState(u) {
  811. const displayList = this[`${this.currentTab}DisplayList`];
  812. // 修改状态
  813. changeUserStatus({liveId: u.liveId, userId: u.userId}).then(response => {
  814. let { code } = response;
  815. if (200 === code) {
  816. u.msgStatus = u.msgStatus === 0 ? 1 : 0
  817. // 同步更新消息列表中相同用户的状态
  818. this.msgList.forEach(msg => {
  819. if (msg.userId === u.userId) {
  820. msg.msgStatus = u.msgStatus;
  821. }
  822. });
  823. displayList.forEach(user => {
  824. if (user.userId === u.userId) {
  825. user.msgStatus = u.msgStatus;
  826. }
  827. });
  828. // 4. 关键:重新筛选所有Tab的显示列表,确保状态同步
  829. this.refreshUserDisplayLists(u);
  830. let msg = u.msgStatus === 0 ? "已解禁" : "已禁言"
  831. this.msgSuccess(msg);
  832. return
  833. }
  834. this.msgError("操作失败");
  835. })
  836. },
  837. refreshUserDisplayLists(user) {
  838. const { userId, msgStatus: newStatus, online } = user;
  839. const oldStatus = newStatus === 1 ? 0 : 1; // 操作前的状态(反向)
  840. // 1. 禁言操作(newStatus=1):从原在线/离线列表移除,加入禁言列表
  841. if (newStatus === 1) {
  842. // 从在线/离线列表中移除该用户(根据当前在线状态)
  843. if (online === 0) {
  844. this.onlineDisplayList = this.onlineDisplayList.filter(u => u.userId !== userId);
  845. this.userTotal.online = Math.max(0, this.userTotal.online - 1);
  846. } else {
  847. this.offlineDisplayList = this.offlineDisplayList.filter(u => u.userId !== userId);
  848. this.userTotal.offline = Math.max(0, this.userTotal.offline - 1);
  849. }
  850. this.userTotal.silenced = this.userTotal.silenced + 1;
  851. // 添加到禁言列表(去重+限制长度)
  852. const silencedList = this.silencedDisplayList.filter(u => u.userId !== userId);
  853. silencedList.push(user);
  854. this.silencedDisplayList = silencedList.slice(-40);
  855. }
  856. // 2. 解禁操作(newStatus=0):从禁言列表移除,回到原在线/离线列表
  857. else {
  858. // 从禁言列表移除
  859. this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== userId);
  860. this.userTotal.silenced = Math.max(0, this.userTotal.silenced - 1);
  861. // 回到对应的在线/离线列表(根据当前在线状态)
  862. if (online === 0) {
  863. // 加入在线列表(去重+限制长度)
  864. const onlineList = this.onlineDisplayList.filter(u => u.userId !== userId);
  865. onlineList.push(user);
  866. this.onlineDisplayList = onlineList.slice(-40);
  867. this.userTotal.online = this.userTotal.online + 1;
  868. } else {
  869. // 加入离线列表(去重+限制长度)
  870. const offlineList = this.offlineDisplayList.filter(u => u.userId !== userId);
  871. offlineList.push(user);
  872. this.offlineDisplayList = offlineList.slice(-40);
  873. this.userTotal.offline = this.userTotal.offline + 1;
  874. }
  875. }
  876. },
  877. blockUser(u){
  878. this.$confirm('是否确认封禁用户账号为:"' + u.nickName + '-' + u.userId + '"?', "警告", {
  879. confirmButtonText: "确定",
  880. cancelButtonText: "取消",
  881. type: "warning"
  882. }).then(function() {
  883. return blockUser(u.userId);
  884. }).then(() => {
  885. if(u.msgStatus === 1){
  886. this.userTotal.silenced -= 1
  887. this.silencedDisplayList = this.silencedDisplayList.filter(user => user.userId !== u.userId)
  888. } else if( u.online === 0){
  889. this.userTotal.online -= 1
  890. this.onlineDisplayList = this.onlineDisplayList.filter(user => user.userId !== u.userId)
  891. } else {
  892. this.userTotal.offline -= 1
  893. this.offlineDisplayList = this.offlineDisplayList.filter(user => user.userId !== u.userId)
  894. }
  895. this.userTotal.al -= 1
  896. this.alDisplayList = this.alDisplayList.filter(user => user.userId !== u.userId)
  897. let msg = {
  898. msg: "",
  899. liveId: this.liveId,
  900. userId: u.userId,
  901. userType: 0,
  902. cmd: 'blockUser',
  903. avatar: this.$store.state.user.user.avatar,
  904. nickName: this.$store.state.user.user.nickName
  905. }
  906. this.socket.send(JSON.stringify(msg))
  907. this.msgSuccess("封禁成功");
  908. }).catch(() => {});
  909. },
  910. searchUsers(){
  911. this.resetUserParams()
  912. this.loadNextPage(this.currentTab)
  913. },
  914. handleUserClick(tab){
  915. const tabName = tab.name;
  916. this.currentTab = tabName;
  917. const params = this.pageParams[tabName];
  918. const displayList = this[`${tabName}DisplayList`];
  919. // 首次切换到该Tab或列表为空时初始化
  920. if (displayList.length < 20) {
  921. // 重置分页参数
  922. params.currentPage = 1;
  923. params.pageSize = 20;
  924. params.prevPage = 0;
  925. params.totalLoaded = 0;
  926. params.hasMore = true;
  927. params.hasPrev = false;
  928. // 加载第一页
  929. this.loadNextPage(tabName);
  930. } else {
  931. // 非首次切换,恢复滚动位置
  932. this.$nextTick(() => {
  933. const scrollEl = this.getScrollElement(tabName);
  934. if (scrollEl) {
  935. scrollEl.scrollTop = this.tabScrollPositions[tabName] || 0;
  936. }
  937. });
  938. }
  939. },
  940. loadNextPage(tabName) {
  941. const params = this.pageParams[tabName];
  942. const displayList = this[`${tabName}DisplayList`];
  943. // 若没有更多数据或正在加载,直接返回
  944. if (!params.hasMore || this.scrLoading[tabName].next) {
  945. return;
  946. }
  947. this.scrLoading[tabName].next = true;
  948. const queryParams = {
  949. liveId: this.liveId,
  950. pageNum: params.currentPage,
  951. pageSize: 20,
  952. online: tabName === 'online' ? 0 : 1,
  953. msgStatus: tabName === 'silenced' ? 1 : 0,
  954. all: tabName === 'al' ? 1 : 0,
  955. userName: this.searchKeyword
  956. };
  957. // 调用接口加载对应状态的分页数据(需后端支持按状态筛选)
  958. dashBoardWatchUserList(queryParams).then(response => {
  959. this.scrLoading[tabName].next = false;
  960. if (response.code !== 200) return;
  961. const { rows, total } = response;
  962. params.total = total; // 记录总数据量
  963. // 过滤重复数据(基于userId)
  964. const newRows = rows.filter(row =>
  965. !displayList.some(u => u.userId === row.userId)
  966. );
  967. displayList.push(...newRows)
  968. // 添加新数据并限制最大长度(避免内存占用过大)
  969. if (displayList.length >= 40) { // 最大保留100条
  970. this[`${tabName}DisplayList`] = displayList.slice(-40);
  971. // 记录滚动位置(用于加载后校准)
  972. const scrollEl = this.getScrollElement(tabName);
  973. // 校准滚动位置(保持视觉连续性)
  974. this.$nextTick(() => {
  975. if (scrollEl) {
  976. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  977. }
  978. });
  979. }
  980. // 更新分页状态
  981. params.hasMore = params.currentPage * params.pageSize < total;
  982. params.currentPage += 1;
  983. params.hasPrev = params.currentPage > 2; // 当前页>2时一定有上一页
  984. params.prevPage = params.currentPage - 2;
  985. }).catch(() => {
  986. this.scrLoading[tabName].next = false;
  987. });
  988. },
  989. // 新增:加载上一页(向上滚动时)
  990. loadPrevPage(tabName) {
  991. const params = this.pageParams[tabName];
  992. const displayList = this[`${tabName}DisplayList`];
  993. // 边界校验:无上一页/正在加载/当前页<=1
  994. console.log(`加载 ${tabName} 上一页`);
  995. console.log(!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1)
  996. if (!params.hasPrev || this.scrLoading[tabName].prev || params.currentPage <= 1) {
  997. return;
  998. }
  999. this.scrLoading[tabName].prev = true;
  1000. const targetPage = params.prevPage > 0 ? params.prevPage : params.currentPage - 2;
  1001. const queryParams = {
  1002. liveId: this.liveId,
  1003. pageNum: targetPage,
  1004. pageSize: 20,
  1005. online: tabName === 'online' ? 0 : 1,
  1006. msgStatus: tabName === 'silenced' ? 1 : 0,
  1007. all: tabName === 'al' ? 1 : 0,
  1008. userName: this.searchKeyword
  1009. };
  1010. dashBoardWatchUserList(queryParams).then(response => {
  1011. this.scrLoading[tabName].prev = false;
  1012. if (response.code !== 200) return;
  1013. const { rows } = response;
  1014. if (rows.length === 0) {
  1015. params.hasPrev = false;
  1016. return;
  1017. }
  1018. // 记录滚动位置(用于加载后校准)
  1019. const scrollEl = this.getScrollElement(tabName);
  1020. const scrollTop = scrollEl?.scrollTop || 0;
  1021. const itemHeight = 80; // 预估行高(根据实际样式调整)
  1022. const newItemsHeight = rows.length * itemHeight;
  1023. // 过滤重复数据并添加到列表头部
  1024. const newRows = rows.filter(row => !displayList.some(u => u.userId === row.userId));
  1025. this[`${tabName}DisplayList`] = [...newRows, ...displayList];
  1026. params.totalLoaded += newRows.length;
  1027. // 限制最大长度
  1028. if (this[`${tabName}DisplayList`].length > 40) {
  1029. this[`${tabName}DisplayList`] = this[`${tabName}DisplayList`].slice(0, 40);
  1030. }
  1031. // 更新分页状态
  1032. params.prevPage = targetPage - 1;
  1033. params.hasPrev = targetPage > 1; // 上一页页码>1时还有更多上一页
  1034. params.currentPage = params.currentPage - 1;
  1035. if(params.currentPage * 20 < params.total) params.hasMore = true;
  1036. // 校准滚动位置(保持视觉连续性)
  1037. this.$nextTick(() => {
  1038. if (scrollEl) {
  1039. scrollEl.scrollTop = scrollEl.scrollHeight * 0.5;
  1040. }
  1041. });
  1042. }).catch(() => {
  1043. this.scrLoading[tabName].prev = false;
  1044. });
  1045. },
  1046. getList() {
  1047. this.resetUserParams()
  1048. this.resetMsgParams()
  1049. // this.loadUserList()
  1050. this.loadUserTotals(); // 先加载总人数
  1051. // this.handleClick('online')
  1052. // this.handleClick({name:'online'})
  1053. this.loadMsgList()
  1054. this.loadLiveTask()
  1055. },
  1056. formatDate(value) {
  1057. // 检查值是否存在且为日期类型(或可转换为日期)
  1058. if (!value) return '';
  1059. let date;
  1060. // 处理字符串类型的日期
  1061. if (typeof value === 'string') {
  1062. date = new Date(value);
  1063. }
  1064. // 处理Date对象
  1065. else if (value instanceof Date) {
  1066. date = value;
  1067. }
  1068. // 无效类型直接返回原值
  1069. else {
  1070. return value;
  1071. }
  1072. // 检查是否为有效日期
  1073. if (isNaN(date.getTime())) {
  1074. return value;
  1075. }
  1076. // 格式化年月日时分秒(补零处理)
  1077. const year = date.getFullYear();
  1078. const month = String(date.getMonth() + 1).padStart(2, '0');
  1079. const day = String(date.getDate()).padStart(2, '0');
  1080. const hours = String(date.getHours()).padStart(2, '0');
  1081. const minutes = String(date.getMinutes()).padStart(2, '0');
  1082. const seconds = String(date.getSeconds()).padStart(2, '0');
  1083. return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  1084. },
  1085. loadLiveTask(){
  1086. if(!this.taskParams.hasMore) return
  1087. const queryParams = {
  1088. liveId: this.liveId,
  1089. pageSize: 10,
  1090. pageNum: 1
  1091. }
  1092. consoleList(queryParams).then(res => {
  1093. let {code, rows,total} = res;
  1094. if (code === 200) {
  1095. this.taskParams.total = total
  1096. this.timelineItems = rows
  1097. if(rows.length < this.taskParams.pageSize){
  1098. this.taskParams.hasMore = false
  1099. }
  1100. this.startTaskTimer()
  1101. } else {
  1102. this.stopTaskTimer()
  1103. }
  1104. })
  1105. },
  1106. loadMsgList(){
  1107. // 直播间消息
  1108. listLiveSingleMsg({
  1109. liveId:this.liveId,
  1110. pageNum: this.msgParams.pageNum,
  1111. pageSize: this.msgParams.pageSize
  1112. }).then(response => {
  1113. let {code, rows,total} = response;
  1114. if (code === 200) {
  1115. let totalPage = (total % this.msgParams.pageSize == 0) ? Math.floor(total / this.msgParams.pageSize) : Math.floor(total / this.msgParams.pageSize + 1);
  1116. rows.forEach(row => {
  1117. if (!this.msgList.some(m => m.msgId === row.msgId)) {
  1118. let user = this.alDisplayList.find(u => u.userId === row.userId)
  1119. if (user) {
  1120. row.msgStatus = user.msgStatus
  1121. } else {
  1122. row.msgStatus = 0
  1123. }
  1124. this.msgList.push(row)
  1125. }
  1126. })
  1127. this.msgList.reverse()
  1128. // 同步更新消息列表中相同用户的状态
  1129. this.alDisplayList.forEach(u => {
  1130. this.msgList.filter(m => m.userId === u.userId).forEach(m => m.msgStatus = u.msgStatus)
  1131. })
  1132. // 所有消息加载完成后,根据自动滚动状态决定是否滚动
  1133. this.$nextTick(() => {
  1134. setTimeout(() => {
  1135. // 重置加载状态
  1136. this.isLoadingLatest = false;
  1137. if (this.isAutoScrollEnabled) {
  1138. // 如果启用自动滚动,强制滚动到底部并隐藏按钮
  1139. this.scrollToBottom(true);
  1140. } else {
  1141. // 如果禁用自动滚动,检查是否在底部,决定是否显示按钮
  1142. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1143. const wrap = this.$refs.manageRightRef.wrap;
  1144. const scrollHeight = wrap.scrollHeight;
  1145. const clientHeight = wrap.clientHeight;
  1146. const currentScrollTop = wrap.scrollTop;
  1147. const maxScrollTop = scrollHeight - clientHeight;
  1148. if (currentScrollTop < maxScrollTop - 50) {
  1149. this.showLoadLatestBtn = true;
  1150. } else {
  1151. this.showLoadLatestBtn = false;
  1152. }
  1153. }
  1154. }
  1155. }, 300);
  1156. });
  1157. }
  1158. })
  1159. // 添加滚动监听
  1160. this.$nextTick(() => {
  1161. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1162. this.$refs.manageRightRef.wrap.addEventListener("scroll", this.manageRightScroll)
  1163. }
  1164. })
  1165. },
  1166. // 消息滚动监听(带防抖)
  1167. manageRightScroll() {
  1168. // 清除之前的防抖定时器
  1169. if (this.scrollDebounceTimer) {
  1170. clearTimeout(this.scrollDebounceTimer);
  1171. }
  1172. // 设置防抖,300ms内只执行一次
  1173. this.scrollDebounceTimer = setTimeout(() => {
  1174. this.saveChatScrollPosition();
  1175. // 检查是否滚动到底部
  1176. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1177. const wrap = this.$refs.manageRightRef.wrap;
  1178. const scrollHeight = wrap.scrollHeight;
  1179. const clientHeight = wrap.clientHeight;
  1180. const currentScrollTop = wrap.scrollTop;
  1181. const maxScrollTop = scrollHeight - clientHeight;
  1182. // 如果滚动到底部(距离底部小于50px),隐藏按钮并恢复自动滚动
  1183. if (currentScrollTop >= maxScrollTop - 50) {
  1184. this.showLoadLatestBtn = false;
  1185. this.isAutoScrollEnabled = true;
  1186. }
  1187. }
  1188. }, 300);
  1189. },
  1190. loadUserTotals() {
  1191. if (!this.liveId) return;
  1192. // 假设后端提供一个接口返回总人数(如果没有,可通过首次加载全量数据后统计)
  1193. getLiveUserTotals({ liveId: this.liveId }).then(res => {
  1194. if (res.code === 200) {
  1195. this.userTotal = res.data; // { online, offline, silenced }
  1196. }
  1197. });
  1198. },
  1199. toggleBlack(user) {
  1200. user.isBlack = !user.isBlack;
  1201. },
  1202. toggleMute(user) {
  1203. user.isMuted = !user.isMuted;
  1204. },
  1205. toggleVisible(msg) {
  1206. msg.isVisible = !msg.isVisible;
  1207. },
  1208. deleteMessage(msg) {
  1209. const index = this.messages.indexOf(msg);
  1210. if (index > -1) {
  1211. this.messages.splice(index, 1);
  1212. }
  1213. },
  1214. addTimelineItem() {
  1215. this.timelineItems.push({ time: '00:00', action: '新动作' });
  1216. },
  1217. removeTimelineItem(item) {
  1218. const index = this.timelineItems.indexOf(item);
  1219. if (index > -1) {
  1220. this.timelineItems.splice(index, 1);
  1221. }
  1222. },
  1223. resetUserParams() {
  1224. // 重置各Tab的显示列表和分页参数
  1225. this.alDisplayList = [];
  1226. this.onlineDisplayList = []; // 在线用户显示列表
  1227. this.offlineDisplayList = []; // 离线用户显示列表
  1228. this.silencedDisplayList = []; // 禁言用户显示列表
  1229. this.pageParams= {
  1230. al: {
  1231. currentPage: 1,
  1232. pageSize: 20,
  1233. prevPage: 0,
  1234. totalLoaded: 0,
  1235. total: 0,
  1236. hasMore: true,
  1237. hasPrev: false
  1238. },
  1239. online: {
  1240. currentPage: 1, // 当前页(下一页加载用)
  1241. pageSize: 20, // 当前页(下一页加载用)
  1242. prevPage: 0, // 上一页页码(上一页加载用)
  1243. totalLoaded: 0, // 已加载总条数
  1244. total: 0, // 总数据量
  1245. hasMore: true, // 是否有下一页
  1246. hasPrev: false // 是否有上一页
  1247. },
  1248. offline: {
  1249. currentPage: 1,
  1250. pageSize: 20,
  1251. prevPage: 0,
  1252. totalLoaded: 0,
  1253. total: 0,
  1254. hasMore: true,
  1255. hasPrev: false
  1256. },
  1257. silenced: {
  1258. currentPage: 1,
  1259. pageSize: 20,
  1260. prevPage: 0,
  1261. totalLoaded: 0,
  1262. total: 0,
  1263. hasMore: true,
  1264. hasPrev: false
  1265. }
  1266. },
  1267. this.scrLoading = {
  1268. al: { next: false, prev: false },
  1269. online: { next: false, prev: false },
  1270. offline: { next: false, prev: false },
  1271. silenced: { next: false, prev: false }
  1272. }
  1273. },
  1274. resetMsgParams() {
  1275. // 消息参数保留
  1276. this.msgList = [];
  1277. this.msgParams = {
  1278. pageNum: 1,
  1279. pageSize: 30,
  1280. liveId: this.liveId
  1281. };
  1282. // 重置时不改变按钮状态,由后续的滚动逻辑决定
  1283. this.taskParams = {
  1284. currentPage: 1,
  1285. pageSize: 20,
  1286. prevPage: 0,
  1287. totalLoaded: 0,
  1288. total: 0,
  1289. hasMore: true,
  1290. hasPrev: false
  1291. }
  1292. },
  1293. getScrollElement(tabName) {
  1294. const scrollRefs = {
  1295. al: this.$refs.manageLeftRef_al,
  1296. online: this.$refs.manageLeftRef_online,
  1297. offline: this.$refs.manageLeftRef_offline,
  1298. silenced: this.$refs.manageLeftRef_silenced
  1299. };
  1300. return scrollRefs[tabName]?.wrap;
  1301. },
  1302. // 初始化滚动监听(在mounted中调用)
  1303. initScrollListeners() {
  1304. // 为每个Tab的滚动容器添加监听
  1305. this.$nextTick(() => {
  1306. const scrollRefs = {
  1307. al: this.$refs.manageLeftRef_al,
  1308. online: this.$refs.manageLeftRef_online,
  1309. offline: this.$refs.manageLeftRef_offline,
  1310. silenced: this.$refs.manageLeftRef_silenced
  1311. };
  1312. Object.keys(scrollRefs).forEach(tabName => {
  1313. const scrollEl = scrollRefs[tabName]?.wrap;
  1314. if (scrollEl) {
  1315. scrollEl.addEventListener('scroll', () =>
  1316. this.handleTabScroll(tabName, scrollEl)
  1317. );
  1318. }
  1319. });
  1320. });
  1321. },
  1322. // 处理Tab滚动事件(判断是否触底)
  1323. handleTabScroll(tabName, scrollEl) {
  1324. const { scrollTop, scrollHeight, clientHeight } = scrollEl;
  1325. const bottomThreshold = 50; // 距离底部100px触发下一页
  1326. const topThreshold = 50; // 距离顶部100px触发上一页
  1327. // 加载下一页(滚动到底部附近)
  1328. if (scrollHeight - scrollTop - clientHeight < bottomThreshold) {
  1329. this.loadNextPage(tabName);
  1330. }
  1331. // 加载上一页(滚动到顶部附近)
  1332. if (scrollTop < topThreshold) {
  1333. this.loadPrevPage(tabName);
  1334. }
  1335. },
  1336. // 恢复聊天滚动位置
  1337. restoreChatScrollPosition() {
  1338. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1339. this.$refs.manageRightRef.wrap.scrollTop = this.chatScrollTop;
  1340. }
  1341. },
  1342. // 保存聊天滚动位置
  1343. saveChatScrollPosition() {
  1344. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1345. this.chatScrollTop = this.$refs.manageRightRef.wrap.scrollHeight - this.$refs.manageRightRef.wrap.clientHeight;
  1346. }
  1347. },
  1348. /**
  1349. * 停止任务检测定时器
  1350. */
  1351. stopTaskTimer() {
  1352. if (this.taskTimer) {
  1353. clearInterval(this.taskTimer);
  1354. this.taskTimer = null;
  1355. }
  1356. },
  1357. /**
  1358. * 启动任务检测定时器
  1359. */
  1360. startTaskTimer() {
  1361. // 先清除已有定时器,避免重复
  1362. if (this.taskTimer) {
  1363. clearInterval(this.taskTimer);
  1364. }
  1365. // 立即执行一次检查
  1366. this.checkTaskExpiration();
  1367. // 启动定时器,定期检查
  1368. this.taskTimer = setInterval(() => {
  1369. this.checkTaskExpiration();
  1370. }, this.checkInterval);
  1371. },
  1372. /**
  1373. * 检查时间轴第一条任务是否过期
  1374. */
  1375. checkTaskExpiration() {
  1376. // 如果没有任务,直接返回
  1377. if (!this.timelineItems || this.timelineItems.length === 0) {
  1378. this.stopTaskTimer()
  1379. return;
  1380. };
  1381. // 获取第一条任务的时间
  1382. const firstTask = this.timelineItems[0];
  1383. const taskTime = new Date(firstTask.absValue).getTime();
  1384. const currentTime = new Date().getTime();
  1385. // 如果任务时间已过当前时间(过期),重新加载任务列表
  1386. if (taskTime < currentTime) {
  1387. this.timelineItems.shift()
  1388. this.loadLiveTask(); // 重新加载任务列表
  1389. }
  1390. },
  1391. },
  1392. beforeDestroy() {
  1393. if (this.autoMsgTimer != null) {
  1394. clearInterval(this.autoMsgTimer);
  1395. }
  1396. if (this.autoScrollTimer) {
  1397. clearTimeout(this.autoScrollTimer);
  1398. }
  1399. if (this.scrollDebounceTimer) {
  1400. clearTimeout(this.scrollDebounceTimer);
  1401. }
  1402. // 移除滚动监听器
  1403. if (this.$refs.manageRightRef && this.$refs.manageRightRef.wrap) {
  1404. this.$refs.manageRightRef.wrap.removeEventListener("scroll", this.manageRightScroll);
  1405. }
  1406. },
  1407. // 使用 deactivated 和 activated 钩子替代 beforeDestroy 和 destroyed
  1408. deactivated() {
  1409. this.saveChatScrollPosition();
  1410. },
  1411. activated() {
  1412. this.$nextTick(() => {
  1413. this.restoreChatScrollPosition();
  1414. });
  1415. // todo yhq
  1416. // this.$nextTick(() => {
  1417. // const video = this.$refs.videoPlayer;
  1418. // if (video != null) {
  1419. // this.initVideoPlayer(this.liveInfo.startTime)
  1420. // }
  1421. // })
  1422. },
  1423. };
  1424. </script>
  1425. <style scoped>
  1426. .console {
  1427. display: flex;
  1428. height: 100vh;
  1429. }
  1430. .left-panel, .middle-panel, .right-panel {
  1431. padding: 20px;
  1432. box-sizing: border-box;
  1433. }
  1434. .left-panel {
  1435. width: 30%;
  1436. background: #f8fafc;
  1437. border-right: 1px solid #e2e8f0;
  1438. }
  1439. .middle-panel {
  1440. width: 40%;
  1441. background: #f8fafc;
  1442. border-right: 1px solid #e2e8f0;
  1443. }
  1444. .right-panel {
  1445. width: 30%;
  1446. background: #f8fafc;
  1447. }
  1448. .search {
  1449. margin: 10px 0;
  1450. }
  1451. .search input {
  1452. width: 70%;
  1453. padding: 8px;
  1454. border: 1px solid #cbd5e1;
  1455. border-radius: 4px;
  1456. }
  1457. .search button {
  1458. padding: 8px 15px;
  1459. background: #3b82f6;
  1460. color: #fff;
  1461. border: none;
  1462. border-radius: 4px;
  1463. cursor: pointer;
  1464. }
  1465. .tabs {
  1466. display: flex;
  1467. margin: 10px 0;
  1468. }
  1469. .tabs button {
  1470. padding: 8px 15px;
  1471. border: 1px solid #e2e8f0;
  1472. background: #fff;
  1473. cursor: pointer;
  1474. }
  1475. .tabs button.active {
  1476. background: #3b82f6;
  1477. color: #fff;
  1478. border-color: #3b82f6;
  1479. }
  1480. .user-list {
  1481. max-height: 600px;
  1482. overflow-y: auto;
  1483. }
  1484. .user-item {
  1485. display: flex;
  1486. align-items: center;
  1487. padding: 10px;
  1488. border-bottom: 1px solid #e2e8f0;
  1489. }
  1490. .user-item img {
  1491. width: 40px;
  1492. height: 40px;
  1493. border-radius: 50%;
  1494. margin-right: 10px;
  1495. }
  1496. .user-info {
  1497. flex: 1;
  1498. }
  1499. .user-name {
  1500. font-weight: bold;
  1501. }
  1502. .user-status {
  1503. font-size: 12px;
  1504. color: #64748b;
  1505. }
  1506. .online {
  1507. color: #10b981;
  1508. }
  1509. .offline {
  1510. color: #94a3b8;
  1511. }
  1512. .user-actions {
  1513. display: flex;
  1514. }
  1515. .user-actions button {
  1516. padding: 5px 10px;
  1517. margin-left: 5px;
  1518. border: none;
  1519. border-radius: 4px;
  1520. cursor: pointer;
  1521. }
  1522. .block {
  1523. background: #ef4444;
  1524. color: #fff;
  1525. }
  1526. .unblock {
  1527. background: #10b981;
  1528. color: #fff;
  1529. }
  1530. .mute {
  1531. background: #f59e0b;
  1532. color: #fff;
  1533. }
  1534. .unmute {
  1535. background: #3b82f6;
  1536. color: #fff;
  1537. }
  1538. .system-messages, .discussion-messages {
  1539. margin: 20px 0;
  1540. background: #fff;
  1541. padding: 15px;
  1542. border-radius: 8px;
  1543. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1544. }
  1545. .system-messages textarea {
  1546. width: 100%;
  1547. height: 100px;
  1548. border: 1px solid #e2e8f0;
  1549. border-radius: 4px;
  1550. padding: 8px;
  1551. box-sizing: border-box;
  1552. }
  1553. .message-actions {
  1554. margin-top: 10px;
  1555. }
  1556. .message-actions button {
  1557. padding: 5px 10px;
  1558. margin-right: 5px;
  1559. background: #3b82f6;
  1560. color: #fff;
  1561. border: none;
  1562. border-radius: 4px;
  1563. cursor: pointer;
  1564. }
  1565. .message-list {
  1566. max-height: 300px;
  1567. overflow-y: auto;
  1568. margin-top: 10px;
  1569. }
  1570. .message-item {
  1571. display: flex;
  1572. margin-bottom: 10px;
  1573. padding-bottom: 10px;
  1574. border-bottom: 1px solid #e2e8f0;
  1575. }
  1576. .message-avatar img {
  1577. width: 30px;
  1578. height: 30px;
  1579. border-radius: 50%;
  1580. margin-right: 10px;
  1581. }
  1582. .message-content {
  1583. flex: 1;
  1584. }
  1585. .message-user {
  1586. font-weight: bold;
  1587. }
  1588. .message-text {
  1589. font-size: 14px;
  1590. color: #64748b;
  1591. }
  1592. .message-actions button {
  1593. padding: 3px 8px;
  1594. font-size: 12px;
  1595. background: #3b82f6;
  1596. color: #fff;
  1597. border: none;
  1598. border-radius: 4px;
  1599. cursor: pointer;
  1600. }
  1601. .live-player, .automation, .watermark {
  1602. margin: 20px 0;
  1603. background: #fff;
  1604. padding: 15px;
  1605. border-radius: 8px;
  1606. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  1607. }
  1608. .timeline-items {
  1609. margin: 10px 0;
  1610. }
  1611. .timeline-item {
  1612. display: flex;
  1613. justify-content: space-between;
  1614. align-items: center;
  1615. padding: 8px 0;
  1616. border-bottom: 1px solid #e2e8f0;
  1617. }
  1618. .delete {
  1619. background: #ef4444;
  1620. color: #fff;
  1621. border: none;
  1622. border-radius: 4px;
  1623. padding: 3px 8px;
  1624. cursor: pointer;
  1625. }
  1626. .add {
  1627. background: #10b981;
  1628. color: #fff;
  1629. border: none;
  1630. border-radius: 4px;
  1631. padding: 8px 15px;
  1632. cursor: pointer;
  1633. }
  1634. .watermark-settings textarea {
  1635. width: 100%;
  1636. height: 100px;
  1637. border: 1px solid #e2e8f0;
  1638. border-radius: 4px;
  1639. padding: 8px;
  1640. box-sizing: border-box;
  1641. }
  1642. .watermark-options {
  1643. margin-top: 10px;
  1644. }
  1645. .watermark-options label {
  1646. display: block;
  1647. margin-bottom: 5px;
  1648. }
  1649. /* 隐藏 el-scrollbar 的横向滚动条 */
  1650. .el-scrollbar__wrap {
  1651. overflow-x: hidden !important;
  1652. }
  1653. .custom-scrollbar .el-scrollbar__wrap {
  1654. overflow-x: hidden !important;
  1655. }
  1656. .scrollbar-demo-item{
  1657. display: flex;
  1658. align-items: center;
  1659. justify-content: center;
  1660. height: 50px;
  1661. margin: 10px;
  1662. text-align: center;
  1663. border-radius: 4px;
  1664. }
  1665. .message-container {
  1666. position: relative;
  1667. }
  1668. .load-latest-btn {
  1669. position: absolute;
  1670. bottom: 20px;
  1671. right: 20px;
  1672. z-index: 10;
  1673. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  1674. }
  1675. </style>