最近正好项目中有个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>
样式你们自己适配,不弄了,最终效果