VUE+websocket实现后台管理系统实时客服聊天IM模块

最近正好项目中有个IM的实时客服功能上线,研究了一下把代码发出来给大家参考。
复制到项目中替换一下API接口可以直接使用
<template>

  <div class="wrap">

    <!-- 底部 -->
    <div class="infoBox">
      <!-- 左边用户列表 -->
      <div class="userList">
        <div class="searchBox">
          <el-input
            placeholder="请输入内容"
            v-model="search"
            class="input-with-select"
            size="mini"
            @input="inquire"
          >
            <i
              class="el-icon-search el-input__icon"
              slot="suffix"
              @click="handleIconClick"
            >
            </i>
          </el-input>

        </div>
        <div class="userListBox">
          <div
            v-for="(item, index) in userListData"
            :key="index"
            @click="getAct(item, index)"
            :class="item.oppositeId == act ? 'userFlexAct' : 'userFlex'"
          >
            <div class="unread-count">
              <img
                :src="(item.sendType === 1 ? item.receiveAvatarUrl : item.avatarUrl) || require('../../assets/images/user.png')"
                class="head_portrait2"
                style="margin-left: 20px;margin-right: 5px"
              />
              <div :class="{ 'unreadCount': item.unreadCount > 0 }"></div>
            </div>
            <div style="margin-right: 40px">
              <el-tooltip
                :content="item.sendType === 1 ? item.receiveNickName : item.nickName"
                placement="bottom"
                effect="light"
              >
                <div
                  style="color: #565656"
                  class="userName"
                >
                  {{ item.sendType === 1 ? item.receiveNickName : item.nickName }}
                </div>
              </el-tooltip>
              <div class="userInfo"><span v-show="item.unreadCount">[{{ item.unreadCount }}条]</span> {{ item.content }}
              </div>
            </div>
            <div style="margin-right: 10px; font-size: 14px; color: #ccc">
              {{ dateFormat(item.addTime, 'HH:mm:ss') }}
            </div>
          </div>
        </div>
      </div>
      <!-- 右边输入框和信息展示 -->
      <div class="infoList">
        <!-- 信息 -->
        <div
          v-show="act"
          class="infoTop"
          ref="scrollBox"
          id="box"
        >
          <div
            :class="item.sendType !== 1 ? 'chatInfoLeft' : 'chatInfoRight'
              "
            v-for="(item, index) in userInfoList"
            :key="index"
          >
            <img
              :src="item.avatarUrl || require('../../assets/images/user.png')"
              class="head_portrait2"
            />
            <div :class="item.sendType !== 1 ? 'chatLeft' : 'chatRight'">
              <div
                class="text"
                v-if="item.picUrls[0] === ''"
                v-html="item.content"
              ></div>
              <el-image
                v-else
                style="width: 70px; height: 70px"
                :src="item.picUrls[0]"
                :preview-src-list="item.picUrls"
              >
              </el-image>
            </div>
          </div>
        </div>
        <!-- 输入框 -->
        <div
          v-show="act"
          class="infoBottom"
        >
          <div class="infoIcon">
            <el-upload
              :headers="headers"
              :show-file-list="false"
              :on-success="handleAvatarSuccess"
              accept="image/jpg,image/jpeg,image/png"
              :action="uploadImgUrl"
              :before-upload="beforeAvatarUpload"
            >
              <i class="el-icon-picture-outline-round"></i>
            </el-upload>

            <!--              <i @click="extend('发送商品')" class="el-icon-sell"></i>-->
            <!--              <i @click="extend('设置')" class="el-icon-setting"></i>-->
            <!--              <i @click="extend('聊天记录')" class="el-icon-chat-dot-round"></i>-->
            <!--              <i @click="extend('更多选项')" class="el-icon-more-outline"></i>-->
          </div>
          <textarea
            type="textarea"
            class="infoInput"
            v-model="textarea"
            @keydown.enter.exact="handlePushKeyword($event)"
            @keyup.ctrl.enter="lineFeed"
            :disabled="isshow == 1 ? false : true"
          />
          <div
            class="fasong"
            @click="setUp"
            v-show="isshow == 1 ? true : false"
          >
            发送
          </div>
        </div>
      </div>
    </div>
  </div>

</template>

JS部分

<script>
import { history, messageList, messageRead } from "@/api/onlineservice";
import { getTenantInfo } from "@/api/tenant";
import { getTenantId, getToken } from "@/utils/auth";
import { uploadPath } from "@/api/storage";

export default {
  data () {
    return {

      headers: {
        "X-Litemall-Admin-Token": getToken(),
        "X-Litemall-TenantId": getTenantId(),
      },
      uploadImgUrl: uploadPath, // 上传的图片服务器地址
      //websocket部分
      path: `ws://192.168.0.200:6915/adminWebsocket/${this.$store.state.user.token}`, //后台的websocket地址
      ws: null, //建立的连接
      lockReconnect: false, //是否真正建立连接
      timeout: 10 * 1000, //30秒一次心跳
      timeoutObj: null, //心跳心跳倒计时
      serverTimeoutObj: null, //心跳倒计时
      timeoutnum: null, //断开 重连倒计时

      // 在线状态
      state: 1,
      //搜索用户
      search: "",
      //用户列表渲染数据
      userListData: [],
      //用户列表筛选数据
      userListDatas: [],
      //用户点击选中变色
      act: null,
      sendUserId: null,
      // 加号弹框
      dialogVisible: false,
      //历史信息
      userInfoList: [],
      //输入框
      textarea: "",
      //滚动条距离顶部距离
      scrollTop: 0,
      //发送和输入显隐
      isshow: 0,
      dataForm: {},
      sendType: '',
    };
  },
  created () {
    console.log(this.$store.state, 111111111)
    this.getTenantInfoList()
    this.getList()
    this.initWebpack();
  },
  beforeDestroy () {
    // 离开页面后关闭连接
    this.ws.close();
    // 清除时间
    clearTimeout(this.timeoutObj);
    clearTimeout(this.serverTimeoutObj);
  },
  methods: {
    handleAvatarSuccess (res, file) {
      console.log(res, file)

      this.ws.send(
        JSON.stringify({
          avatarUrl: this.dataForm.tenantPicUrl,
          nickName: this.dataForm.tenantName,
          content: "",
          picUrls: res.data.url,
          // position: "right",
          receiveUserId: this.sendUserId
        })
      );
    },
    beforeAvatarUpload (file) {
      const isJPG = (file.type === 'image/jpeg' || file.type === 'image/jpeg' || file.type === 'image/png');
      const isLt2M = file.size / 1024 / 1024 < 2;

      if (!isJPG) {
        this.$message.error('请上传图片');
      }
      if (!isLt2M) {
        this.$message.error('上传头像图片大小不能超过 2MB!');
      }
      return isJPG && isLt2M;
    },
    dateFormat (timestamp, format) {
      if (String(timestamp).length === 10) {
        timestamp = timestamp * 1000
      }
      let date = new Date(timestamp)
      let Y = date.getFullYear()
      let M = date.getMonth() + 1
      let D = date.getDate()
      let hour = date.getHours()
      let min = date.getMinutes()
      let sec = date.getSeconds()
      if (format === 'YYYY') {
        return Y // 2021
      } else if (format === 'YYYY-MM') { // 2021-07
        return Y + '-' + (M < 10 ? '0' + M : M)
      } else if (format === 'YYYY-MM-DD') { // 2021-07-12
        return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D)
      } else if (format === 'HH:mm:ss') { // 10:20:35
        return (hour < 10 ? '0' + hour : hour) + ':' + (min < 10 ? '0' + min : min) + ':' + (sec < 10 ? '0' + sec : sec)
      } else if (format === 'YYYY-MM-DD HH:mm') { // 2021-07-12 10:20
        return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D) + ' ' + (hour < 10 ? '0' + hour : hour) + ':' + (min < 10 ? '0' + min : min)
      } else if (format === 'YYYY-MM-DD HH:mm:ss') { // 2021-07-12 10:20:35
        return Y + '-' + (M < 10 ? '0' + M : M) + '-' + (D < 10 ? '0' + D : D) + ' ' + (hour < 10 ? '0' + hour : hour) + ':' + (min < 10 ? '0' + min : min) + ':' + (sec < 10 ? '0' + sec : sec)
      } else {
        return '--'
      }
    },
    getTenantInfoList () {
      getTenantInfo().then((response) => {
        this.dataForm = response.data;
      });
    },
    getList () {
      messageList().then(res => {
        this.userListData = res.data.list
        this.userListDatas = res.data.list
      })
    },
    //搜索icon
    handleIconClick () {
      console.log(1);
    },
    //点击用户
    getAct (val, index) {
      if (this.act === val.oppositeId) return
      this.isshow = 1;
      // 点击用户切换数据时先清除监听滚动事件,防止出现没有历史数据的用户,滚动条为0,会触发滚动事件
      this.$refs.scrollBox.removeEventListener("scroll", this.srTop);
      //点击变色
      this.act = val.oppositeId;
      this.sendUserId = (val.sendType === 1 ? val.receiveUserId : val.sendUserId)
      //清空消息数组
      this.userInfoList = [];
      let params = {
        receiveUserId: val.sendType === 1 ? val.receiveUserId : val.sendUserId,
        limit: 0
      }
      history(params).then(res => {
        this.userInfoList = res.data.list
        messageRead({ "sendUserId": this.sendUserId }).then(() => { this.getList() })
        // 模拟一下点击用户出现历史记录的样子,实际开发中是axios请求后数组赋值然后调用setPageScrollTo
        // 直接调用不生效:因为你历史数据刚给,渲染的时候盒子高度还没有成型,所以直接调用拿不到,用个定时器让他在下一轮循环中调用,盒子就已经生成了
        this.$nextTick(() => { // 一定要用nextTick
          this.setPageScrollTo();
          //页面滚动条距离顶部高度等于这个盒子的高度
          this.$refs.scrollBox.scrollTop = this.$refs.scrollBox.scrollHeight;
        })
      })
    },
    // 模糊搜索用户
    inquire () {

      let fuzzy = this.search;
      if (fuzzy) {
        this.userListData = this.userListDatas.filter((item) => {
          return item.receiveNickName.includes(fuzzy);
        });
      } else {
        this.userListData = this.userListDatas;
      }
    },
    //发送
    setUp () {
      console.log("发送内容:", this.textarea);
      // this.userInfoList.push({
      //   avatarUrl: this.dataForm.tenantName.tenantPicUrl,
      //   nickName: this.dataForm.tenantName,
      //   content: this.textarea,
      //   picUrls: "",
      //   // position: "right",
      //   receiveUserId: this.sendUserId,
      //   sendType: 1
      // });
      this.ws.send(
        JSON.stringify({
          avatarUrl: this.dataForm.tenantPicUrl,
          nickName: this.dataForm.tenantName,
          content: this.textarea,
          picUrls: "",
          // position: "right",
          receiveUserId: this.sendUserId
        })
      );
      this.textarea = "";
      // 页面滚动到底部
      this.$nextTick(() => { // 一定要用nextTick
        this.setPageScrollTo();
        //页面滚动条距离顶部高度等于这个盒子的高度
        this.$refs.scrollBox.scrollTop = this.$refs.scrollBox.scrollHeight;
      })
    },
    // 监听键盘回车阻止换行并发送
    handlePushKeyword (event) {
      console.log(event);
      if (event.keyCode === 13) {
        event.preventDefault(); // 阻止浏览器默认换行操作
        this.setUp(); //发送文本
        return false;
      }
    },
    // 监听按的是ctrl + 回车,就换行
    lineFeed () {
      console.log("换行");
      this.textarea = this.textarea + "\n";
    },
    //点击icon
    extend (val) {
      alert("你点击了:" + val);
    },
    //滚动条默认滚动到最底部
    setPageScrollTo (s, c) {
      //获取中间内容盒子的可见区域高度
      this.scrollTop = document.querySelector("#box").offsetHeight;
      setTimeout((res) => {
        //加个定时器,防止上面高度没获取到,再获取一遍。
        if (this.scrollTop != this.$refs.scrollBox.offsetHeight) {
          this.scrollTop = document.querySelector("#box").offsetHeight;
        }
      }, 100);
      //scrollTop:滚动条距离顶部的距离。
      //把上面获取到的高度座位距离,把滚动条顶到最底部
      this.$refs.scrollBox.scrollTop = this.scrollTop;
      //判断是否有滚动条,有滚动条就创建一个监听滚动事件,滚动到顶部触发srTop方法
      if (this.$refs.scrollBox.scrollTop > 0) {
        this.$refs.scrollBox.addEventListener("scroll", this.srTop);
      }
    },
    //滚动条到达顶部
    srTop () {
      //判断:当滚动条距离顶部为0时代表滚动到顶部了
      if (this.$refs.scrollBox.scrollTop == 0) {
        //逻辑简介:
        //到顶部后请求后端的方法,获取第二页的聊天记录,然后插入到现在的聊天数据前面。
        //如何插入前面:可以先把获取的数据保存在 A 变量内,然后 this.userInfoList=A.concat(this.userInfoList)把数组合并进来就可以了

        //拿聊天记录逻辑:
        //第一次调用一个请求拉历史聊天记录,发请求时参数带上页数 1 传过去,拿到的就是第一页的聊天记录,比如一次拿20条。你显示出来
        //然后向上滚动到顶部时,触发新的请求,在请求中把分页数先 +1 然后再请求,这就拿到了第二页数据,然后通过concat合并数组插入进前面,依次类推,功能完成!
        // alert("已经到顶部了");
      }
    },

    //-----------------------以下是websocket部分方法

    // 初始化websocket链接
    initWebpack () {
      if (typeof WebSocket === "undefined") {
        alert("您的浏览器不支持socket");
      } else {
        this.ws = new WebSocket(this.path); //实例
        this.ws.onopen = this.onopen; //监听链接成功
        this.ws.onmessage = this.onmessage; //监听后台返回消息
        this.ws.onclose = this.onclose; //监听链接关闭
        this.ws.onerror = this.onerror; //监听链接异常
      }
    },
    //重新连接
    reconnect () {
      var that = this;
      if (that.lockReconnect) {
        return;
      }
      that.lockReconnect = true;
      //没连接上会一直重连,设置延迟避免请求过多
      that.timeoutnum && clearTimeout(that.timeoutnum);
      that.timeoutnum = setTimeout(function () {
        that.initWebpack(); //新连接
        that.lockReconnect = false;
      }, 5000);
    },
    //重置心跳
    reset () {
      var that = this;
      clearTimeout(that.timeoutObj); //清除心跳倒计时
      clearTimeout(that.serverTimeoutObj); //清除超时关闭倒计时
      that.start(); //重启心跳
    },
    //开启心跳
    start () {
      var self = this;
      self.timeoutObj && clearTimeout(self.timeoutObj); //心跳倒计时如果有值就清除掉,防止重复
      self.serverTimeoutObj && clearTimeout(self.serverTimeoutObj); //超时关闭倒计时如果有值就清除掉,防止重复
      //然后从新开一个定时器
      self.timeoutObj = setTimeout(function () {
        //这里通过readyState判断链接状态,有四个值,0:正在连接,1:已连接,2:正在断开,3:已经断开或者链接不成功
        if (self.ws.readyState == 1) {
          //如果连接正常,给后天发送一个值,可以自定义,然后后台返回我们一个信息,我们接收到后会触发onmessage方法回调
          self.ws.send(
            JSON.stringify({ "token": getToken() })
          );
        } else {
          //如果检测readyState不等于1那也就代表不处在链接状态,那就是不正常的,那就调用重连方法
          self.reconnect();
        }
        //从新赋值一个超时计时器,这个定时器的作用:当你触发心跳的时候可能会出现一个情况,后台崩了,前台发了个心跳,没有回应,就不会触发onmessage方法
        //所以我们需要在这个心跳发送出去了后,再开一个定时器,用于监控心跳返回的时间,比如10秒,那么10秒内如果后台回我了,触发onmessage方法,自然就会把心跳时间和超时倒计时一起清空掉
        //也就不会触发这个关闭连接,但是如果10秒后还是没有收到回应,那么就会触发关闭连接,而关闭连接方法内又会触发重连方法,循环就走起来了。
        self.serverTimeoutObj = setTimeout(function () {
          //如果超时了就关闭连接
          self.ws.close();
        }, self.timeout);
      }, self.timeout);
    },
    //连接成功
    onopen () {
      if (this.ws.readyState == 1) {
        //如果连接正常,给后天发送一个值,可以自定义,然后后台返回我们一个信息,我们接收到后会触发onmessage方法回调
        this.ws.send(
          JSON.stringify({ "token": getToken() })
        );
      }
      // this.reset(); //链接成功后开启心跳
    },
    //接受后台信息回调
    onmessage (e) {
      /**这里写自己的业务逻辑代码**/
      console.log("收到后台信息:", JSON.parse(e.data));
      let data = JSON.parse(e.data)
      if (this.act) {
        if (data[0].sendType === 1) {
          this.userInfoList.push(...data);
        } else {
          if (this.act === (data[0].nickName.sendType === 1 ? data[0].receiveUserId : data[0].sendUserId)) {
            this.userInfoList.push(...data);
          }
        }

      }
      if (this.act) {
        messageRead({ "sendUserId": this.sendUserId }).then(() => { this.getList() })
      } else {
        this.getList()
      }
      this.$nextTick(() => { // 一定要用nextTick
        this.setPageScrollTo();
        //页面滚动条距离顶部高度等于这个盒子的高度
        this.$refs.scrollBox.scrollTop = this.$refs.scrollBox.scrollHeight;
      })
      this.reset(); //收到服务器信息,心跳重置
    },
    //关闭连接回调
    onclose (e) {
      console.log("连接关闭");
      this.reconnect(); //重连
    },
    //连接异常回调
    onerror (e) {
      console.log("出现错误");
      this.reconnect(); //重连
    },
  },
};
</script>

样式你们自己适配,不弄了,最终效果

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
实现在Spring Boot和Vue中使用WebSocket实现实时聊天的过程如下: 1. 后端使用Spring Boot,首先需要在pom.xml文件中添加依赖项以支持WebSocket和Spring Boot的WebSocket集成。例如: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> ``` 2. 创建一个WebSocket配置类,用于配置WebSocket处理程序和端点。例如: ```java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatHandler(), "/chat").setAllowedOrigins("*"); } } ``` 3. 创建WebSocket处理程序,用于处理WebSocket连接、消息发送和接收。例如: ```java @Component public class ChatHandler extends TextWebSocketHandler { private static final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.add(session); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); for (WebSocketSession currentSession : sessions) { currentSession.sendMessage(new TextMessage(payload)); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session); } } ``` 4. 在Vue中使用`vue-native-websocket`或`vue-socket.io`等库来创建WebSocket连接并处理消息。例如: ```javascript import VueNativeSock from 'vue-native-websocket' Vue.use(VueNativeSock, 'ws://localhost:8080/chat', { format: 'json', reconnection: true, store: VuexStore // 如果需要将消息存储到Vuex中,可以提供一个Vuex store }) ``` 5. 在Vue组件中使用WebSocket连接,发送和接收消息。例如: ```javascript this.$socket.send('Hello') // 发送消息 this.$socket.onMessage((message) => { console.log(message) // 收到消息 }) ``` 通过上述步骤,就可以在Spring Boot和Vue中使用WebSocket实现实时聊天功能。当用户在Vue组件中发送消息时,消息将通过WebSocket连接发送到后端的Spring Boot应用程序,然后由WebSocket处理程序将消息广播给所有连接的客户端。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

时光已回不到旧日

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值