在線(xiàn)聊天技術(shù)選型
在線(xiàn)聊天因?yàn)樯婕暗交ハ嗤ㄐ牛圆捎胹ocket.io
前端框架 vue2
打包工具 vite
在線(xiàn)gitee地址: https://gitee.com/service-chat/service-chat
整體架構(gòu)
初始化之后的效果如下:
init 初始化
init 主要是從url參數(shù)中獲取用戶(hù)的id贼陶,然后調(diào)用signalrService
// 初始化
init() {
this.sender.id = parseInt(this.$route.query.sendId);
if (!(this.sender.id > 0)) {
alert("請(qǐng)?zhí)砑觭endId參數(shù)");
return false;
}
// 當(dāng)前產(chǎn)品
let product = this.$store.state.productList.filter(
(x) => x.Id === this.$route.query.productId
);
if (product.length > 0) {
// 卡片信息內(nèi)容
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;
}
// 當(dāng)前用戶(hù)
let userInfo = this.$store.state.userList.filter(
(x) => x.id == this.sender.id
)[0];
// 快速回復(fù)
this.fastReplay = this.$store.state.fastReply;
if (userInfo) {
this.sender.name = userInfo.name;
// 修改昵稱(chēng)時(shí)的臨時(shí)記錄昵稱(chēng)
this.temporaryUserName = userInfo.name;
this.sender.isService = userInfo.isService;
this.sender.receptNum = userInfo.receptNum;
// 修改接待用戶(hù)數(shù)量時(shí)的臨時(shí)記錄接待用戶(hù)數(shù)量
this.temporaryReceptNumber = userInfo.receptNum;
} else {
alert("請(qǐng)保證sendId參數(shù)在userList.json文件中存在");
return false;
}
// 發(fā)送歡迎語(yǔ)
let welCome = this.$store.state.robotReply.filter(
(x) => x.Answer.indexOf("歡迎語(yǔ)") !== -1
);
if (welCome.length > 0) {
this.signalrService(welCome[0], 1, 4, false);
}
},
signalrService
當(dāng)初次初始化的時(shí)候,只是把當(dāng)前的內(nèi)容發(fā)送到當(dāng)前會(huì)話(huà)內(nèi)容里邊去鸠澈。
// 1.信息組裝
// 發(fā)送者身份:0 機(jī)器人顿苇,1 客服員巷挥,2.會(huì)員
// 信息類(lèi)型 :0 文本蜂怎,1 圖片楼吃,2 表情,3 商品卡片/訂單卡片亲轨,4 機(jī)器人回復(fù)
signalrService(
content,
identity,
type,
isSendOther = true,
isRobot = false
) {
// 發(fā)送信息
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,
// 發(fā)送時(shí)間戳
NoCode: noCode,
OutTradeNo: this.revicer.outTradeNo,
CreateDateUtc: createDate,
Title: null,
Description: null,
Label: null,
Thumbnail: null,
NoSend: true,
};
// 發(fā)送到當(dāng)前會(huì)員內(nèi)容里邊中
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("發(fā)送太快啦趋惨,請(qǐng)稍后再試");
}
}
和機(jī)器人對(duì)話(huà)
如果客服是機(jī)器人的話(huà),用戶(hù)依然可以發(fā)送一些信息給機(jī)器人惦蚊,比如發(fā)送一些信息器虾,效果如下:
當(dāng)然也可以點(diǎn)擊機(jī)器人發(fā)送過(guò)來(lái)的信息,比如查看如何操作退款蹦锋,如何操作提貨等
發(fā)送信息給機(jī)器人
可以和機(jī)器人聊天兆沙,可以把一些用戶(hù)常見(jiàn)的問(wèn)題,形成標(biāo)準(zhǔn)答案莉掂,當(dāng)用戶(hù)輸入的問(wèn)題的時(shí)候葛圃,如果用戶(hù)輸入的問(wèn)題在問(wèn)題庫(kù)里邊,可以直接按照標(biāo)準(zhǔn)問(wèn)題答案進(jìn)行回復(fù)憎妙。
發(fā)送消息給機(jī)器人是使用的sendToRobot
// 機(jī)器人聊天
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,
};
// 把消息加入到消息會(huì)話(huà)內(nèi)容里邊
this.toSendInfo(this.infoTemplate);
// 把信息拉到最低下库正,因?yàn)橄⑿枰故咀钚碌? this.toBottom(100);
// 觸發(fā)socket的sendToRobot事件
this.socket.emit("sendToRobot", this.infoTemplate);
// 設(shè)定一個(gè)時(shí)間,如果超過(guò)了固定時(shí)間厘唾,就設(shè)置為發(fā)送失敗
this.sendFailed(this.infoTemplate);
} else {
return null;
}
}
在后端接收sendToRobot事件褥符,然后看看是否有發(fā)送過(guò)來(lái)問(wèn)題的固定答案,然后觸發(fā)changOrShowMsg
//發(fā)送信息給機(jī)器人
socket.on("sendToRobot", (data) => {
let welCome = robotReply.filter(
(x) => x.Answer.indexOf(data.Content) !== -1
);
socket.emit("reviceFromRobot", {
content:
welCome.length > 0
? welCome[0]
: "非常對(duì)不起哦抚垃,不知道怎么回答這個(gè)問(wèn)題呢喷楣,我會(huì)努力學(xué)習(xí)的。",
flag: welCome.length > 0 ? true : false,
});
socket.emit("changOrShowMsg", data);
});
當(dāng)前端接收到changOrShowMsg后鹤树,把消息設(shè)置為發(fā)送成功
// 修改信息狀態(tài)
this.socket.on("changOrShowMsg", (data) => {
this.sendState = true;
// 清除sendFailed設(shè)置的定時(shí)器铣焊,然后設(shè)置成功
clearTimeout(this.msgTimer);
this.conversition.forEach((x) => {
if (x.NoCode !== null && x.NoCode === data.NoCode) {
x.State = 1;
}
});
});
人工聊天
如果覺(jué)得客服機(jī)器人不能滿(mǎn)足需求的時(shí)候,可以通過(guò)點(diǎn)擊轉(zhuǎn)人工轉(zhuǎn)人工客服魂迄,和京東淘寶都類(lèi)似粗截,因?yàn)楹芏嗲闆r下,機(jī)器人都不能滿(mǎn)足用戶(hù)的需求捣炬,所以需要轉(zhuǎn)人工
客服不在線(xiàn)
調(diào)用函數(shù)是callPeople
// 呼叫客服
callPeople() {
// 顯示loading
this.loading();
// 呼叫客服
this.joinChat();
},
呼叫客服熊昌,其實(shí)就是看看有沒(méi)有客服在線(xiàn)
//加入會(huì)話(huà)
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,
});
},
在后端監(jiān)聽(tīng)joinChat事件,邏輯比較清晰湿酸,就是監(jiān)聽(tīng)到有用戶(hù)想加入進(jìn)來(lái)的時(shí)候婿屹,判斷當(dāng)前的是否有客服在線(xiàn),如果有客服在線(xiàn)推溃,則看下是否有空閑時(shí)間的客服昂利,如果每個(gè)客服都很忙,達(dá)到了最大服務(wù)用戶(hù)數(shù)量铁坎,則顯示客服較忙蜂奸,稍微再等會(huì),如果有空閑的客服硬萍,則把客服分配服務(wù)于當(dāng)前用戶(hù)扩所。
// 加入聊天
socket.on("joinChat", (data) => {
let serviceList = null;
let index = 0;
// 如果發(fā)送消息的不是客服
if (!data.IsService) {
// 當(dāng)前登錄的客服列表
serviceList = users.filter((x) => x.IsService === true);
// 當(dāng)前登錄的客服列表的人數(shù)
let serviceCount = serviceList.length;
for (let i = serviceCount - 1; i >= 0; i--) {
let item = serviceList[i];
// 當(dāng)前登錄的用戶(hù)列表
let number = users.filter((x) => x.ReviceId === item.SendId).length;
// 當(dāng)前客服可以接待的最大用戶(hù)數(shù)量
let num = userList.filter((x) => x.id === item.SendId)[0].receptNum;
// 如果當(dāng)前登錄的用戶(hù)數(shù)量大于當(dāng)前客服可以接待的數(shù)量,把該客服刪除
if (number >= num) {
serviceList.splice(i, 1);
}
}
// 如果當(dāng)前登錄的客服數(shù)量大于0并且每個(gè)客服已經(jīng)達(dá)到的最大的服務(wù)用戶(hù)數(shù)量
if (serviceCount > 0 && serviceList.length <= 0) {
socket.emit("joinError", {
msg: "當(dāng)前咨詢(xún)?nèi)藬?shù)較多朴乖,請(qǐng)稍后再試",
});
return;
// 還有剩余客服
} else if (serviceList.length > 0) {
// 隨機(jī)分配客服
index = randomNum(0, serviceList.length - 1);
socket.emit("joinTip", {
ReviceName: serviceList[index].SendName,
ReviceId: serviceList[index].SendId,
ReviceOutTradeNo: serviceList[index].OutTradeNo,
});
// 讓會(huì)員加入房間
socket.join(serviceList[index].OutTradeNo);
// 如果沒(méi)有客服在線(xiàn)祖屏,則返回暫無(wú)客服在線(xiàn)
} else {
socket.emit("joinError", {
msg: "暫無(wú)客服在線(xiàn)",
});
return;
}
} else {
// 如果發(fā)送消息的是客服,則加入到聊天室里邊
socket.join(socket.id);
}
// 若該用戶(hù)已登錄买羞,將舊設(shè)備登錄的用戶(hù)強(qiáng)制下線(xiàn)袁勺,多個(gè)用戶(hù)多端登錄
let oldUser = users.filter((x) => x.SendId === data.SendId);
if (oldUser.length > 0) {
socket.to(oldUser[0].OutTradeNo).emit("squeezeOut", {
noCode: oldUser[0].NoCode,
});
}
// 存在用戶(hù)信息時(shí)將舊記錄刪除并且重新記錄
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 + "加入會(huì)話(huà)",
UnRead: 0,
CloseSession: false,
};
// 用戶(hù)重新加入
users.push(user);
// 把登錄成功的sendId記錄下來(lái)
socket.SendId = data.SendId;
io.emit("joinSuccess", {
user,
users,
});
});
前面沒(méi)有上線(xiàn)客服,所以當(dāng)用戶(hù)想轉(zhuǎn)人工的時(shí)候畜普,只能顯示暫無(wú)客服期丰,現(xiàn)在看下客服端是什么樣的。
效果如下:
客服可以設(shè)置上線(xiàn)或者離線(xiàn)吃挑,當(dāng)客服上線(xiàn)之后钝荡,這個(gè)時(shí)候,當(dāng)用戶(hù)選擇客服聊天后儒鹿,就可以選擇客服了化撕。
調(diào)用
// 修改在線(xiàn)狀態(tài)
changeOnLine() {
if (!this.sender.onlineState) {
this.loading();
// 客服上線(xiàn)
this.socket.emit("joinChat", {
SendId: this.sender.id,
SendName: this.sender.name,
ReviceId: -1,
ReviceName: this.revicer.name,
IsService: true,
NoCode: this.noCode,
});
} else {
// 離線(xiàn)
this.loading();
this.isSelectSession = false;
this.socket.emit("offLine", {
SendId: this.sender.id,
NoCode: this.noCode,
});
}
},
后端如果接收到客服上線(xiàn),就把客服加入到socket,也就是joinChat
// 如果發(fā)送消息的是客服约炎,則加入到聊天室里邊
socket.join(socket.id);
如果客服已經(jīng)在線(xiàn)了植阴,就可以轉(zhuǎn)人工和客服聊天了,
// 隨機(jī)分配客服
index = randomNum(0, serviceList.length - 1);
socket.emit("joinTip", {
ReviceName: serviceList[index].SendName,
ReviceId: serviceList[index].SendId,
ReviceOutTradeNo: serviceList[index].OutTradeNo,
});
// 讓會(huì)員加入房間
socket.join(serviceList[index].OutTradeNo);
可以看到后端接收到信息后圾浅,觸發(fā)joinTip掠手,然后用戶(hù)就可以和客服聊天了。
發(fā)送信息狸捕,通過(guò)后端通過(guò)sendMsg來(lái)處理
// 發(fā)送消息
socket.on("sendMsg", (data) => {
// 設(shè)置用戶(hù)未讀
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: "您已掉線(xiàn)喷鸽,請(qǐng)重新連接",
});
return;
}
if (revicer.length < 0) {
socket.emit("offLineTip", {
msg: "對(duì)方已離線(xiàn)",
});
return;
}
data.State = 1;
// 向socket觸發(fā)reviceMsg
socket.to(data.OutTradeNo).emit("reviceMsg", data);
socket.emit("changOrShowMsg", data);
});
可以看到,是通過(guò)socket.to(data.OutTradeNo).emit("reviceMsg", data); 來(lái)觸發(fā)
// 接收信息
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);
});
發(fā)送圖片
不管是用戶(hù)或者是客服發(fā)送圖片都是調(diào)用sendMsg
//發(fā)送圖片
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("請(qǐng)選擇圖片文件!", { 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(); //新建一個(gè)img標(biāo)簽(還沒(méi)嵌入DOM節(jié)點(diǎn))
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);
// 顯示出來(lái)
this.signalrService(data, identity, 1);
this.$refs.referenceUpload.value = null;
};
};
} else if (fileObj.size > 1024 * 1024 * 10) {
return alert("上傳圖片不能超過(guò)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;
};
}
}
},
后面的處理就和發(fā)送文字類(lèi)似了
發(fā)送表情
發(fā)送表情是直接把圖片作為發(fā)送內(nèi)容進(jìn)行發(fā)送的灸拍,使用如下代碼:
<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多平臺(tái)發(fā)布