开发一个在线聊天
在线聊天技术选型
在线聊天因为涉及到互相通信,所以采用socket.io
前端框架 vue2
打包工具 vite
在线gitee地址: https://gitee.com/service-chat/service-chat
整体架构
初始化之后的效果如下:
init 初始化
init 主要是从url参数中获取用户的id,然后调用signalrService
// 初始化
init() {
this.sender.id = parseInt(this.$route.query.sendId);
if (!(this.sender.id > 0)) {
alert("请添加sendId参数");
return false;
}
// 当前产品
let product = this.$store.state.productList.filter(
(x) => x.Id === this.$route.query.productId
);
if (product.length > 0) {
// 卡片信息内容
this.browseCard.Id = product[0].Id;
this.browseCard.Name = product[0].Name;
this.browseCard.ShortDescription = product[0].ShortDescription;
this.browseCard.DefaultPictureUrl = product[0].DefaultPictureUrl;
this.browseCard.Amount = "编码:" + product[0].ProductCode;
this.browseCard.Type = 1;
}
// 当前用户
let userInfo = this.$store.state.userList.filter(
(x) => x.id == this.sender.id
)[0];
// 快速回复
this.fastReplay = this.$store.state.fastReply;
if (userInfo) {
this.sender.name = userInfo.name;
// 修改昵称时的临时记录昵称
this.temporaryUserName = userInfo.name;
this.sender.isService = userInfo.isService;
this.sender.receptNum = userInfo.receptNum;
// 修改接待用户数量时的临时记录接待用户数量
this.temporaryReceptNumber = userInfo.receptNum;
} else {
alert("请保证sendId参数在userList.json文件中存在");
return false;
}
// 发送欢迎语
let welCome = this.$store.state.robotReply.filter(
(x) => x.Answer.indexOf("欢迎语") !== -1
);
if (welCome.length > 0) {
this.signalrService(welCome[0], 1, 4, false);
}
},
signalrService
当初次初始化的时候,只是把当前的内容发送到当前会话内容里边去。
// 1.信息组装
// 发送者身份:0 机器人,1 客服员,2.会员
// 信息类型 :0 文本,1 图片,2 表情,3 商品卡片/订单卡片,4 机器人回复
signalrService(
content,
identity,
type,
isSendOther = true,
isRobot = false
) {
// 发送信息
if (this.sendState) {
let createDate = this.nowTime();
let noCode = +new Date();
this.infoTemplate = {
SendId: this.sender.id,
ReviceId: isRobot ? 0 : this.revicer.id,
Content: content,
Identity: identity,
Type: type,
State: isRobot || !this.sender.onlineState ? 1 : 0,
// 发送时间戳
NoCode: noCode,
OutTradeNo: this.revicer.outTradeNo,
CreateDateUtc: createDate,
Title: null,
Description: null,
Label: null,
Thumbnail: null,
NoSend: true,
};
// 发送到当前会员内容里边中
this.toSendInfo(this.infoTemplate);
if (isSendOther) {
this.sendMsg(this.infoTemplate);
}
this.sendState = isRobot || !this.sender.onlineState ? true : false;
this.sendInfo = type === 2 ? this.sendInfo : "";
this.toBottom(100);
} else {
this.showMsg("发送太快啦,请稍后再试");
}
}
和机器人对话
如果客服是机器人的话,用户依然可以发送一些信息给机器人,比如发送一些信息,效果如下:
当然也可以点击机器人发送过来的信息,比如查看如何操作退款,如何操作提货等
发送信息给机器人
可以和机器人聊天,可以把一些用户常见的问题,形成标准答案,当用户输入的问题的时候,如果用户输入的问题在问题库里边,可以直接按照标准问题答案进行回复。
发送消息给机器人是使用的sendToRobot
// 机器人聊天
sendToRobot() {
console.log(1223);
if (this.sendInfo != "") {
let createDate = this.nowTime();
let noCode = +new Date();
let content = this.sendInfo;
this.sendInfo = "";
// 封装消息
this.infoTemplate = {
SendId: this.sender.id,
ReviceId: 0,
Content: content,
Identity: 2,
Type: 0,
State: 0,
NoCode: noCode,
OutTradeNo: null,
CreateDateUtc: createDate,
Title: null,
Description: null,
Label: null,
Thumbnail: null,
NoSend: true,
};
// 把消息加入到消息会话内容里边
this.toSendInfo(this.infoTemplate);
// 把信息拉到最低下,因为消息需要展示最新的
this.toBottom(100);
// 触发socket的sendToRobot事件
this.socket.emit("sendToRobot", this.infoTemplate);
// 设定一个时间,如果超过了固定时间,就设置为发送失败
this.sendFailed(this.infoTemplate);
} else {
return null;
}
}
在后端接收sendToRobot事件,然后看看是否有发送过来问题的固定答案,然后触发changOrShowMsg
//发送信息给机器人
socket.on("sendToRobot", (data) => {
let welCome = robotReply.filter(
(x) => x.Answer.indexOf(data.Content) !== -1
);
socket.emit("reviceFromRobot", {
content:
welCome.length > 0
? welCome[0]
: "非常对不起哦,不知道怎么回答这个问题呢,我会努力学习的。",
flag: welCome.length > 0 ? true : false,
});
socket.emit("changOrShowMsg", data);
});
当前端接收到changOrShowMsg后,把消息设置为发送成功
// 修改信息状态
this.socket.on("changOrShowMsg", (data) => {
this.sendState = true;
// 清除sendFailed设置的定时器,然后设置成功
clearTimeout(this.msgTimer);
this.conversition.forEach((x) => {
if (x.NoCode !== null && x.NoCode === data.NoCode) {
x.State = 1;
}
});
});
人工聊天
如果觉得客服机器人不能满足需求的时候,可以通过点击转人工转人工客服,和京东淘宝都类似,因为很多情况下,机器人都不能满足用户的需求,所以需要转人工
客服不在线
调用函数是callPeople
// 呼叫客服
callPeople() {
// 显示loading
this.loading();
// 呼叫客服
this.joinChat();
},
呼叫客服,其实就是看看有没有客服在线
//加入会话
joinChat() {
// 呼叫客服
this.socket.emit("joinChat", {
SendId: this.sender.id,
ReviceId: this.revicer.id,
SendName: this.sender.name,
ReviceName: this.revicer.name,
IsService: this.sender.isService,
NoCode: this.noCode,
});
},
在后端监听joinChat事件,逻辑比较清晰,就是监听到有用户想加入进来的时候,判断当前的是否有客服在线,如果有客服在线,则看下是否有空闲时间的客服,如果每个客服都很忙,达到了最大服务用户数量,则显示客服较忙,稍微再等会,如果有空闲的客服,则把客服分配服务于当前用户。
// 加入聊天
socket.on("joinChat", (data) => {
let serviceList = null;
let index = 0;
// 如果发送消息的不是客服
if (!data.IsService) {
// 当前登录的客服列表
serviceList = users.filter((x) => x.IsService === true);
// 当前登录的客服列表的人数
let serviceCount = serviceList.length;
for (let i = serviceCount - 1; i >= 0; i--) {
let item = serviceList[i];
// 当前登录的用户列表
let number = users.filter((x) => x.ReviceId === item.SendId).length;
// 当前客服可以接待的最大用户数量
let num = userList.filter((x) => x.id === item.SendId)[0].receptNum;
// 如果当前登录的用户数量大于当前客服可以接待的数量,把该客服删除
if (number >= num) {
serviceList.splice(i, 1);
}
}
// 如果当前登录的客服数量大于0并且每个客服已经达到的最大的服务用户数量
if (serviceCount > 0 && serviceList.length <= 0) {
socket.emit("joinError", {
msg: "当前咨询人数较多,请稍后再试",
});
return;
// 还有剩余客服
} else if (serviceList.length > 0) {
// 随机分配客服
index = randomNum(0, serviceList.length - 1);
socket.emit("joinTip", {
ReviceName: serviceList[index].SendName,
ReviceId: serviceList[index].SendId,
ReviceOutTradeNo: serviceList[index].OutTradeNo,
});
// 让会员加入房间
socket.join(serviceList[index].OutTradeNo);
// 如果没有客服在线,则返回暂无客服在线
} else {
socket.emit("joinError", {
msg: "暂无客服在线",
});
return;
}
} else {
// 如果发送消息的是客服,则加入到聊天室里边
socket.join(socket.id);
}
// 若该用户已登录,将旧设备登录的用户强制下线,多个用户多端登录
let oldUser = users.filter((x) => x.SendId === data.SendId);
if (oldUser.length > 0) {
socket.to(oldUser[0].OutTradeNo).emit("squeezeOut", {
noCode: oldUser[0].NoCode,
});
}
// 存在用户信息时将旧记录删除并且重新记录
users = users.filter((x) => x.SendId !== data.SendId);
let user = {
SendId: data.SendId,
SendName: data.SendName,
ReviceId: serviceList ? serviceList[index].SendId : data.ReviceId,
ReviceName: serviceList ? serviceList[index].SendName : data.ReviceName,
NoCode: data.NoCode,
OutTradeNo: socket.id,
Room: data.IsService ? socket.io : serviceList[index].OutTradeNo,
IsService: data.IsService,
IsSelect: false,
SessionContent: data.SendName + "加入会话",
UnRead: 0,
CloseSession: false,
};
// 用户重新加入
users.push(user);
// 把登录成功的sendId记录下来
socket.SendId = data.SendId;
io.emit("joinSuccess", {
user,
users,
});
});
前面没有上线客服,所以当用户想转人工的时候,只能显示暂无客服,现在看下客服端是什么样的。
效果如下:
客服可以设置上线或者离线,当客服上线之后,这个时候,当用户选择客服聊天后,就可以选择客服了。
调用
// 修改在线状态
changeOnLine() {
if (!this.sender.onlineState) {
this.loading();
// 客服上线
this.socket.emit("joinChat", {
SendId: this.sender.id,
SendName: this.sender.name,
ReviceId: -1,
ReviceName: this.revicer.name,
IsService: true,
NoCode: this.noCode,
});
} else {
// 离线
this.loading();
this.isSelectSession = false;
this.socket.emit("offLine", {
SendId: this.sender.id,
NoCode: this.noCode,
});
}
},
后端如果接收到客服上线,就把客服加入到socket,也就是joinChat
// 如果发送消息的是客服,则加入到聊天室里边
socket.join(socket.id);
如果客服已经在线了,就可以转人工和客服聊天了,
// 随机分配客服
index = randomNum(0, serviceList.length - 1);
socket.emit("joinTip", {
ReviceName: serviceList[index].SendName,
ReviceId: serviceList[index].SendId,
ReviceOutTradeNo: serviceList[index].OutTradeNo,
});
// 让会员加入房间
socket.join(serviceList[index].OutTradeNo);
可以看到后端接收到信息后,触发joinTip,然后用户就可以和客服聊天了。
发送信息,通过后端通过sendMsg来处理
// 发送消息
socket.on("sendMsg", (data) => {
// 设置用户未读
users.map((x) => {
if (x.SendId === data.SendId) {
x.SessionContent = data.Content;
x.UnRead = 1;
return x;
}
});
//
let sender = users.filter((x) => x.SendId === data.SendId);
let revicer = users.filter((x) => x.SendId === data.ReviceId);
if (sender.length < 0) {
socket.emit("offLineTip", {
msg: "您已掉线,请重新连接",
});
return;
}
if (revicer.length < 0) {
socket.emit("offLineTip", {
msg: "对方已离线",
});
return;
}
data.State = 1;
// 向socket触发reviceMsg
socket.to(data.OutTradeNo).emit("reviceMsg", data);
socket.emit("changOrShowMsg", data);
});
可以看到,是通过socket.to(data.OutTradeNo).emit("reviceMsg", data); 来触发
// 接收信息
this.socket.on("reviceMsg", (data) => {
if (this.sender.isService && data.ReviceId == this.sender.id) {
this.playMusic();
this.currentSessionPeople.forEach((x) => {
if (x.SendId === data.SendId) {
if (!x.IsSelect) x.UnRead++;
switch (data.Type) {
case 0:
x.SessionContent = data.Content;
break;
case 1:
x.SessionContent = "图片";
break;
case 2:
x.SessionContent = "表情";
break;
case 3:
x.SessionContent = "卡片";
break;
}
}
});
}
if (this.sender.onlineState) this.toSendInfo(data);
});
发送图片
不管是用户或者是客服发送图片都是调用sendMsg
//发送图片
sendImage(e) {
const fileObj = e.target.files[0];
let identity = this.sender.isService ? 1 : 2;
if (fileObj != null) {
// 判断是否是图片
if (!/image\/\w+/.test(fileObj.type)) {
return alert("请选择图片文件!", { icon: 5, time: 1000 });
}
var fd = new FormData();
fd.append("file", fileObj);
// 判断图片大小
if (fileObj.size > 1024 * 1024 * 2 && fileObj.size < 1024 * 1024 * 10) {
let reader = new FileReader();
reader.readAsDataURL(fileObj);
reader.onload = (e) => {
let image = new Image(); //新建一个img标签(还没嵌入DOM节点)
image.src = e.target.result;
image.onload = () => {
let canvas = document.createElement("canvas"),
context = canvas.getContext("2d"),
imageWidth = image.width / 2, //压缩后图片的大小
imageHeight = image.height / 2,
data = "";
canvas.width = imageWidth;
canvas.height = imageHeight;
context.drawImage(image, 0, 0, imageWidth, imageHeight);
data = canvas.toDataURL("image/jpeg");
let newFile = this.dataURLtoFile(data); //压缩完成
fd = new FormData();
fd.append("file", newFile);
// 显示出来
this.signalrService(data, identity, 1);
this.$refs.referenceUpload.value = null;
};
};
} else if (fileObj.size > 1024 * 1024 * 10) {
return alert("上传图片不能超过10M!", { icon: 5, time: 1000 });
} else {
let reader = new FileReader();
reader.readAsDataURL(fileObj);
reader.onload = (e) => {
this.signalrService(e.target.result, identity, 1);
this.$refs.referenceUpload.value = null;
};
}
}
},
后面的处理就和发送文字类似了
发送表情
发送表情是直接把图片作为发送内容进行发送的,使用如下代码:
<template v-for="(item, index) in expressions">
<li>
<img
class="customerSendExpression"
v-bind:src="item.image"
v-bind:title="item.title"
@click="toSend(item.image, 2, 2)"
/>
</li>
</template>
本文由mdnice多平台发布