commentBox.vue 18 KB

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