commentBox.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. <template>
  2. <cover-view style="background-color: red;">
  3. <template v-if="openCommentStatus==2">
  4. <!-- <text v-for="(item, index) in activeDanmus" :key="item.commentId" class="danmu-item danmuMove"
  5. :style="{
  6. top: item.top + 'px',
  7. ...item.style,
  8. 'animation-duration': '8s'
  9. }" @animationend="animationend(item,index)">
  10. {{ item.content }}
  11. </text> -->
  12. </template>
  13. <view class="container-body" id="msglist" v-if="openCommentStatus==1">
  14. <view class="listbox" v-for="(item, index) in msgs" :key="index" :id="'view' + index">
  15. <text :class="userId&&item.userId == userId?'list-name my':'list-name'">
  16. {{userId&&item.userId == userId ? '我' : item.nickName||'--'}}:
  17. </text>
  18. <text class="list-con">{{item.content||''}}</text>
  19. </view>
  20. <view class="empty" v-if="msgs&&msgs.length==0">暂无评论~</view>
  21. </view>
  22. </cover-view>
  23. </template>
  24. <script>
  25. import { saveMsg,revokeMsg,getComments} from "@/api/course.js"
  26. export default {
  27. props: {
  28. height:{
  29. type: String,
  30. default:'0px'
  31. },
  32. urlOption:{
  33. type: Object,
  34. default:{}
  35. },
  36. time:{
  37. type: [String,Number],
  38. default: 0
  39. },
  40. viewCommentNum:{
  41. type: [String,Number],
  42. default: 200
  43. },
  44. openCommentStatus:{
  45. type: [String,Number],
  46. default: 3
  47. },
  48. // 用户自己开启关闭弹幕展示 1,展示弹幕,0 关闭的弹幕
  49. showDanmu:{
  50. type: [String,Number],
  51. default: 1
  52. },
  53. },
  54. data() {
  55. return {
  56. statusBarHeight: uni.getSystemInfoSync().statusBarHeight,
  57. scrollTop: 0,
  58. inputText:"",
  59. isSocketOpen:false,
  60. socket:null,
  61. isSend:true,
  62. commentList:[],
  63. msgs: [],
  64. pageNum: 1,
  65. pageSize: 10,
  66. userInfo: {},
  67. userId: '',
  68. pingpangTimes:null,
  69. // 弹幕
  70. danmuList: [],
  71. tracks:[],
  72. activeDanmus:[],
  73. flagTime: 0,
  74. danmuItemStyle:{
  75. color: '#ffffff',
  76. fontSize: '16px',
  77. border: 'solid 1px #ffffff',
  78. borderRadius: '5px',
  79. padding: '2px 2px',
  80. backgroundColor: 'rgba(255, 255, 255, 0.1)'
  81. },
  82. ctx: null,
  83. danmuIndex:{}
  84. }
  85. },
  86. mounted() {
  87. this.getComments()
  88. this.getUser();
  89. this.initTracks()
  90. if(!this.socket || !this.isSocketOpen) {
  91. this.initSocket()
  92. }
  93. },
  94. methods: {
  95. back() {
  96. uni.navigateBack()
  97. },
  98. getUser() {
  99. const userInfo = uni.getStorageSync('userInfo');
  100. if(userInfo&&JSON.stringify(userInfo)!='{}') {
  101. this.userInfo = JSON.parse(userInfo)
  102. this.userId = this.userInfo.userId || ''
  103. }else {
  104. this.userInfo = {}
  105. this.userId = ''
  106. }
  107. },
  108. getComments() {
  109. let that = this
  110. getComments({
  111. pageNum: this.openCommentStatus==2 ? 1 : this.pageNum,
  112. pageSize: this.openCommentStatus==2 ? this.viewCommentNum : this.pageSize,
  113. courseId: this.urlOption.courseId,
  114. videoId: this.urlOption.videoId,
  115. openCommentStatus: this.openCommentStatus
  116. }).then(res=>{
  117. if(res.code==200){
  118. if(this.openCommentStatus==2) {
  119. this.danmuList = res.data.list.map(item=>({
  120. commentId: item.commentId,
  121. content: item.content,
  122. time: item.time || this.time,
  123. color: "#FFFFFF",
  124. mode: item.mode || "scroll",
  125. top: null,
  126. style: {
  127. color: item.isColor==1 ? item.color || this.danmuItemStyle.color : this.danmuItemStyle.color,//是否彩色1是0否
  128. fontSize: item.fontSize || this.danmuItemStyle.fontSize,
  129. padding: this.danmuItemStyle.padding,
  130. border:this.userInfo.userId ==item.userId ? item.color ? `solid 1px ${item.color}`: this.danmuItemStyle.border : 'none',
  131. borderRadius: this.userInfo.userId==item.userId ? this.danmuItemStyle.borderRadius : 0,
  132. backgroundColor: this.userInfo.userId==item.userId ? this.danmuItemStyle.backgroundColor : 'transparent'
  133. },
  134. }))
  135. this.initDanmuIndex()
  136. that.$emit('getMore',0)
  137. } else if(this.openCommentStatus==1) {
  138. this.danmuList = []
  139. this.activeDanmus = []
  140. this.danmuIndex = {};
  141. let list = res.data.list.reverse()
  142. if (that.pageNum == 1) {
  143. that.commentList = list;
  144. that.handleScrollBottom();
  145. } else {
  146. that.commentList = that.commentList.concat(list);
  147. }
  148. that.msgs = [...list,...that.msgs]
  149. if(that.commentList.length >= res.data.total || that.commentList.length >= Number(this.viewCommentNum||200)) {
  150. that.$emit('getMore',1)
  151. } else {
  152. this.pageNum++
  153. that.$emit('getMore',0)
  154. }
  155. } else {
  156. that.danmuList = []
  157. that.activeDanmus = []
  158. that.danmuIndex = {};
  159. that.commentList = [];
  160. that.msgs = that.msgs;
  161. that.$emit('getMore',0);
  162. }
  163. }
  164. else{
  165. that.danmuList = []
  166. that.danmuIndex = {};
  167. that.commentList = [];
  168. that.msgs = that.msgs;
  169. that.$emit('getMore',0);
  170. }
  171. })
  172. },
  173. saveMsg() {
  174. if (this.inputText == "" || this.inputText.trim() == "") {
  175. uni.showToast({
  176. title: '请输入评论',
  177. icon: "none"
  178. })
  179. return;
  180. }
  181. if (!this.isSend) {
  182. return;
  183. }
  184. const param = {
  185. userId: this.userId || '',
  186. userType: 2, // 1-管理员,2-用户
  187. courseId: this.urlOption.courseId,
  188. videoId: this.urlOption.videoId,
  189. type:1, // 评论类型 1:评论,2:回复,目前没有回复,默认传1就行了
  190. content: this.inputText,
  191. time: this.time,
  192. fontSize: '16px',
  193. mode: "scroll",
  194. color: "#ffffff",
  195. }
  196. saveMsg(param).then(res=>{
  197. if(res.code == 200) {
  198. const status = res.status ? 0 : 1
  199. this.sendMsg(param,status);
  200. } else {
  201. uni.showToast({
  202. title: res.msg,
  203. icon: "none"
  204. })
  205. }
  206. })
  207. },
  208. handleInput(val) {
  209. this.inputText = val
  210. console.log("====")
  211. this.addMsg({msg: this.inputText,time: this.time},2)
  212. // if(!this.isSocketOpen) {
  213. // // 重新发起会话
  214. // this.initSocket('reStart')
  215. // } else {
  216. // this.saveMsg();
  217. // }
  218. },
  219. handleScrollBottom() {
  220. setTimeout(() => {
  221. const query = uni.createSelectorQuery().in(this);
  222. query.select('#msglist')
  223. .boundingClientRect((res) => {
  224. if(res) {
  225. const scrollH = res.height;
  226. this.scrollTop = res.height;
  227. this.$emit('getScrollTop',this.scrollTop)
  228. }
  229. }).exec();
  230. },500);
  231. },
  232. initSocket(type) {
  233. //创建一个socket连接
  234. var userId = this.userInfo.userId;
  235. var that = this;
  236. if (this.socket) {
  237. this.socket.close()
  238. this.socket = null;
  239. }
  240. this.socket = uni.connectSocket({
  241. url: getApp().globalData.wsUrl + "/app/webSocket/" + userId,
  242. multiple: true,
  243. success: res => {
  244. console.log('WebSocket连接已打开1!');
  245. that.isSocketOpen = true
  246. // 保持心跳
  247. if(that.pingpangTimes) {
  248. clearInterval(that.pingpangTimes)
  249. that.pingpangTimes= null
  250. }
  251. that.pingpangTimes=setInterval(()=>{
  252. let data={
  253. userId: that.userId || '',
  254. userType: 2, // 1-管理员,2-用户
  255. courseId: that.urlOption.courseId,
  256. videoId: that.urlOption.videoId,
  257. type:1, // 评论类型 1:评论,2:回复,目前没有回复,默认传1就行了
  258. // msg: that.inputText,
  259. cmd:'heartbeat'
  260. };
  261. that.socket.send({
  262. data: JSON.stringify(data),
  263. success: () => {
  264. // console.log('WebSocket发送心条数据!');
  265. },
  266. fail: () => {
  267. that.isSocketOpen=false
  268. }
  269. });
  270. },15000)
  271. },
  272. error: res => {
  273. console.log(res)
  274. },
  275. })
  276. this.socket.onMessage((res) => {
  277. // console.log("收到消息parse",JSON.parse(res.data))
  278. const redata = JSON.parse(res.data);
  279. if(redata.cmd=="heartbeat"){
  280. //心跳
  281. // console.log("heartbeat")
  282. }else if(redata.cmd=="sendMsg"){
  283. that.isSend=true;
  284. that.addMsg(redata);
  285. }
  286. })
  287. //监听socket打开
  288. this.socket.onOpen(() => {
  289. console.log('WebSocket连接已打开2!');
  290. that.isSocketOpen = true
  291. that.isSend = true;
  292. if(type=='reStart') {
  293. // 重连的时候重新发消息
  294. this.saveMsg()
  295. }
  296. })
  297. //监听socket关闭
  298. this.socket.onClose(() => {
  299. that.isSocketOpen = false
  300. that.socket = null
  301. console.log('WebSocket连接已关闭!');
  302. if(that.pingpangTimes) {
  303. clearInterval(that.pingpangTimes)
  304. that.pingpangTimes= null
  305. }
  306. })
  307. //监听socket错误
  308. this.socket.onError((err) => {
  309. console.log("socket err:",err)
  310. that.isSocketOpen = false
  311. that.socket = null
  312. if(that.pingpangTimes) {
  313. clearInterval(that.pingpangTimes)
  314. that.pingpangTimes= null
  315. }
  316. })
  317. },
  318. sendMsg(param,status) {
  319. if(status == 1) {
  320. this.addMsg({msg: param.content,time: param.time},2)
  321. return
  322. }
  323. if (this.isSocketOpen) {
  324. var userId = this.userInfo.userId;
  325. var data = {
  326. userId: this.userId || '',
  327. userType: 2, // 1-管理员,2-用户
  328. courseId: this.urlOption.courseId,
  329. videoId: this.urlOption.videoId,
  330. type:1, // 评论类型 1:评论,2:回复,目前没有回复,默认传1就行了
  331. msg: param.content,
  332. cmd: 'sendMsg',
  333. time: param.time,
  334. fontSize: '16px',
  335. mode: "scroll",
  336. color: "#ffffff",
  337. };
  338. this.socket.send({
  339. data: JSON.stringify(data),
  340. success: () => {
  341. console.log("发送成功")
  342. this.isSend = false;
  343. },
  344. fail: () => {
  345. console.log("发送失败")
  346. }
  347. });
  348. }
  349. },
  350. addMsg(data,type) {
  351. console.log("弹幕===")
  352. let obj = {}
  353. if (type==2) {
  354. obj = {
  355. content: data.msg,
  356. courseId: this.urlOption.courseId,
  357. type: 1,
  358. userId: this.userId,
  359. userType: 2,
  360. videoId: this.urlOption.videoId,
  361. nickName: '',
  362. time: data.time,
  363. fontSize: data.fontSize,
  364. mode: data.mode,
  365. color: data.color,
  366. }
  367. } else {
  368. obj = {
  369. content: data.msg,
  370. courseId: this.urlOption.courseId,
  371. type: data.type,
  372. userId: data.userId,
  373. userType: data.userType,
  374. videoId: this.urlOption.videoId,
  375. nickName: data.nickName,
  376. time: data.time,
  377. fontSize: data.fontSize,
  378. mode: data.mode,
  379. color: data.color,
  380. }
  381. }
  382. if(this.openCommentStatus == 1){
  383. this.msgs.push(obj)
  384. this.handleScrollBottom();
  385. } else if(this.openCommentStatus == 2) {
  386. this.addDanmuMsg(obj)
  387. }
  388. this.inputText = ""
  389. this.$emit("setInputText")
  390. },
  391. addDanmuMsg(content) {
  392. const id = content.userId +'_' + new Date().getTime()
  393. const mystyle = {
  394. color: content.color || this.danmuItemStyle.color,
  395. fontSize: content.fontSize || this.danmuItemStyle.fontSize,
  396. border: content.color ? `solid 1px ${content.color}`: this.danmuItemStyle.border,
  397. borderRadius: this.danmuItemStyle.borderRadius,
  398. padding: this.danmuItemStyle.padding,
  399. backgroundColor: this.danmuItemStyle.backgroundColor
  400. }
  401. const otherstyle = {
  402. color: content.color || this.danmuItemStyle.color,
  403. fontSize: content.fontSize || this.danmuItemStyle.fontSize,
  404. padding: this.danmuItemStyle.padding,
  405. }
  406. const mode = content.mode || "scroll"
  407. const obj = {
  408. commentId: content.commentId || id,
  409. userId: content.userId,
  410. content: content.content,
  411. time: this.flagTime + 1,
  412. color: content.color || this.danmuItemStyle.color,
  413. style: this.userInfo.userId == content.userId ? mystyle : otherstyle,
  414. top: null
  415. }
  416. if(this.showDanmu == 0) return
  417. // 如果danmuList超过最大大小,移除旧的弹幕
  418. const maxDanmuListSize = 10000; // 设置最大大小
  419. if (this.danmuList.length >= maxDanmuListSize) {
  420. this.danmuList.shift(); // 移除最旧的弹幕
  421. }
  422. this.danmuList.push(obj);
  423. // 更新索引
  424. if (!this.danmuIndex[obj.time]) {
  425. this.danmuIndex[obj.time] = [];
  426. }
  427. this.danmuIndex[obj.time].push(obj);
  428. },
  429. closeWSocket() {
  430. if(this.socket!=null){
  431. this.socket.close()
  432. }
  433. if(this.pingpangTimes) {
  434. clearInterval(this.pingpangTimes)
  435. this.pingpangTimes= null
  436. }
  437. },
  438. initTracks() {
  439. this.tracks = [];
  440. const trackHeight = 22; // 每行高度
  441. const trackCount = 3;
  442. for (let i = 0; i < trackCount; i++) {
  443. this.tracks.push({
  444. top: i * trackHeight + 10,
  445. isFree: true,
  446. releaseTime: 0 // 轨道释放时间
  447. });
  448. }
  449. },
  450. // 获取字体高度
  451. getTextWidth(content) {
  452. if (!this.ctx) {
  453. this.ctx = uni.createCanvasContext('myCanvas')
  454. }
  455. const metrics = this.ctx.measureText(content)
  456. return Math.ceil(metrics.width)
  457. },
  458. // 分配轨道
  459. getFreeTrack(item) {
  460. const screenWidth = uni.getSystemInfoSync().screenWidth;
  461. const width = this.getTextWidth(item.content);
  462. const passWidth = width + screenWidth;
  463. const duration = 8; // 持续时间(秒)
  464. const currentTime = Date.now();
  465. for (let i = 0; i < this.tracks.length; i++) {
  466. if (this.tracks[i].isFree || this.tracks[i].releaseTime <= currentTime) {
  467. this.tracks[i].isFree = false;
  468. this.tracks[i].releaseTime = currentTime + Math.ceil(duration * 1000 / passWidth * width) + 1000;
  469. return this.tracks[i].top;
  470. }
  471. }
  472. // 无可用轨道
  473. if (this.userInfo.userId && item.userId == this.userInfo.userId) {
  474. // console.log("自己发的弹幕");
  475. let trackHeight = this.tracks[this.tracks.length - 1].top;
  476. return Math.random() * trackHeight + 16; // 自己发的弹幕随机高度
  477. } else {
  478. // console.log("无可用轨道");
  479. return 'abandon';
  480. }
  481. },
  482. // 初始化时建立索引
  483. initDanmuIndex() {
  484. this.danmuIndex = {};
  485. this.danmuList.forEach((item) => {
  486. if (!this.danmuIndex[item.time]) {
  487. this.danmuIndex[item.time] = [];
  488. }
  489. this.danmuIndex[item.time].push(item);
  490. });
  491. },
  492. // 检测并激活弹幕
  493. checkDanmu(flagTime) {
  494. this.flagTime = flagTime;
  495. if(this.showDanmu == 0) return;
  496. const newDanmus = this.danmuList.filter((item) => Math.abs(item.time - this.flagTime) < 1);
  497. // 分配轨道高度
  498. const aliveNewDanmus = newDanmus.map((item) => {
  499. if (!item.top) {
  500. item.top = this.getFreeTrack(item);
  501. }
  502. return item;
  503. }).filter((item) => item.top !== 'abandon');
  504. // 添加到活跃列表
  505. this.activeDanmus = [...this.activeDanmus, ...aliveNewDanmus];
  506. this.$emit("getActiveDanmus",this.activeDanmus)
  507. },
  508. animationend(moveItem, i) {
  509. // 移除动画结束的弹幕(性能优化)
  510. this.activeDanmus = this.activeDanmus.filter((item) => item.commentId !== moveItem.commentId)
  511. this.$emit("getActiveDanmus",this.activeDanmus)
  512. },
  513. },
  514. beforeDestroy() {
  515. if(this.socket!=null){
  516. this.socket.close()
  517. }
  518. if(this.pingpangTimes) {
  519. clearInterval(this.pingpangTimes)
  520. this.pingpangTimes= null
  521. }
  522. }
  523. }
  524. </script>
  525. <style scoped lang="scss">
  526. @mixin u-flex($flexD, $alignI, $justifyC) {
  527. display: flex;
  528. flex-direction: $flexD;
  529. align-items: $alignI;
  530. justify-content: $justifyC;
  531. }
  532. .empty {
  533. @include u-flex(row, center, center);
  534. padding: 24rpx 50rpx;
  535. color: #999999;
  536. }
  537. .listbox {
  538. white-space: pre-wrap;
  539. letter-spacing: 1px;
  540. margin-bottom: 16rpx;
  541. }
  542. .list-name {
  543. flex-shrink: 0;
  544. font-family: PingFang SC, PingFang SC;
  545. font-weight: 600;
  546. font-size: 28rpx;
  547. color: #222222;
  548. margin-right: 16rpx;
  549. }
  550. .my {
  551. color: #FF5C03;
  552. }
  553. .list-con {
  554. font-family: PingFang SC, PingFang SC;
  555. font-size: 28rpx;
  556. color: #222222;
  557. }
  558. .nav-bar {
  559. position: fixed;
  560. z-index: 9999;
  561. top: 0;
  562. left: 0;
  563. width: 100%;
  564. overflow: hidden;
  565. .nav-bg {
  566. width: 100%;
  567. height: 100%;
  568. position: absolute;
  569. left: 0;
  570. top: 0;
  571. z-index: 1;
  572. background-color: #fff;
  573. }
  574. &-box {
  575. position: relative;
  576. padding: 0 24rpx;
  577. @include u-flex(row, center, flex-start);
  578. height: 88rpx;
  579. box-sizing: border-box;
  580. z-index: 3;
  581. }
  582. &-left {
  583. width: 100%;
  584. @include u-flex(row, center, flex-start);
  585. overflow: hidden;
  586. image {
  587. flex-shrink: 0;
  588. width: 64rpx;
  589. height: 64rpx;
  590. border-radius: 12rpx 12rpx 12rpx 12rpx;
  591. }
  592. }
  593. &-name {
  594. font-family: PingFang SC, PingFang SC;
  595. font-weight: 600;
  596. font-size: 28rpx;
  597. color: #222222;
  598. }
  599. &-head {
  600. flex: 1;
  601. overflow: hidden;
  602. margin-left: 22rpx;
  603. margin-right: 22rpx;
  604. font-family: PingFang SC, PingFang SC;
  605. font-weight: 400;
  606. font-size: 23rpx;
  607. color: #999999;
  608. }
  609. }
  610. .page-bg {
  611. position: absolute;
  612. top: 0;
  613. left: 0;
  614. }
  615. .container-body {
  616. padding: 32rpx 30rpx;
  617. box-sizing: border-box;
  618. }
  619. .TUI-message-list {
  620. width: 100%;
  621. box-sizing: border-box;
  622. }
  623. .chatinput {
  624. position: fixed;
  625. left: 32rpx;
  626. right: 32rpx;
  627. z-index: 999;
  628. bottom: calc(var(--window-bottom) + 24rpx);
  629. height: 96rpx;
  630. background-color: green;
  631. background: #FFFFFF;
  632. box-shadow: 0rpx 8rpx 21rpx 0rpx rgba(0, 0, 0, 0.1);
  633. border-radius: 24rpx 24rpx 24rpx 24rpx;
  634. @include u-flex(row, center, center);
  635. padding: 0 24rpx;
  636. box-sizing: border-box;
  637. .uni-input {
  638. flex: 1;
  639. margin-right: 32rpx;
  640. font-size: 30rpx;
  641. }
  642. .send {
  643. font-family: PingFang SC, PingFang SC;
  644. font-weight: 400;
  645. font-size: 28rpx;
  646. color: #FFFFFF !important;
  647. flex-shrink: 0;
  648. padding: 0 20rpx;
  649. height: 72rpx;
  650. background: #FF5C03 !important;
  651. border-radius: 8rpx 8rpx 8rpx 8rpx;
  652. &::after {
  653. border: none;
  654. }
  655. }
  656. }
  657. // .danmu-item {
  658. // position: absolute;
  659. // top: 0;
  660. // white-space: nowrap;
  661. // font-size: 16px;
  662. // height: 20px;
  663. // display: inline-flex;
  664. // box-sizing: border-box;
  665. // align-items: center;
  666. // }
  667. // .danmuMove {
  668. // // animation: mymove 8s linear forwards;
  669. // // animation-duration: 8s;
  670. // animation-timing-function: linear;
  671. // animation-delay: 0s;
  672. // animation-iteration-count: 1;
  673. // animation-direction: normal;
  674. // animation-fill-mode: forwards;
  675. // animation-play-state: running;
  676. // animation-name: mymove;
  677. // will-change: transform;
  678. // }
  679. // @keyframes mymove {
  680. // from {
  681. // transform: translateX(100vw);
  682. // }
  683. // to {
  684. // transform: translateX(-100%);
  685. // }
  686. // }
  687. </style>