LiveConsole.vue 57 KB

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