commentBox.vue 17 KB

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