js实战系列之客服系统五:聊天

656 阅读1分钟

系列包含

服务端创建websocket

1. 创建文件socket.js

/* ws创建  */
function socketCreate(fastify) {
    /* 构建ws服务 */
    const ws = new (require('ws').Server)({ port: 9798 });
    ws.on('connection', (sk) => {
        /* 设置一个连接id */
        let id = UUID()
        /* 存储当前链接 */
        fastify.storage.socket[id] = sk
        /* 获取消息 */
        fastify.storage.socket[id].on('message', (msg) => {
            let data = JSON.parse(msg)
            let resMsg = { type: data.type }
            /* 用户 */
            if (data.type === "user") {
                /* 查询当前是否有客服在线 没用等待分配 */
                if (!fastify.storage.loginStatus.length) {
                    /* 存储用户等待分配 */
                    fastify.storage.allocation.push({ id: id, list: [] })
                    resMsg.message = "正在为您安排客服..."
                    resMsg.pid = "-1"
                    resMsg.id = id
                    fastify.storage.socket[id].send(JSON.stringify(resMsg));
                    return
                }
                /* 分配用户到当前连接用户最少的在线客服上 */
                let itemInfo = {}
                fastify.storage.loginStatus.forEach(item => {
                    if (!itemInfo.client) {
                        itemInfo = item
                    }
                    if (item.client.length < itemInfo.client.length) {
                        itemInfo = item
                    }
                });
                resMsg.message = "有什么可以帮到您的~~~"
                resMsg.pid = itemInfo.id
                resMsg.id = id
                fastify.storage.socket[id].send(JSON.stringify(resMsg));
                let resMsgService = {
                    type: "addUser",
                    userList: [{
                        id: id,
                        list: []
                    }]
                }
                fastify.storage.socket[itemInfo.id].send(JSON.stringify(resMsgService));
                return
            }
            /* 客服 */
            if (data.type === "service") {
                /* 替换连接id */
                fastify.storage.socket[data.token] = fastify.storage.socket[id]
                delete fastify.storage.socket[id]
                /* 查询是否有等待的用户 有直接分配到该客服上*/
                if (fastify.storage.allocation.length) {
                    let item = fastify.storage.loginStatus.find(item => item.id === data.token)
                    if (item) {
                        item.client = fastify.storage.allocation
                        resMsg.userList = item.client
                        fastify.storage.socket[data.token].send(JSON.stringify(resMsg));
                        /* 通知用户已经匹配到客服 */
                        fastify.storage.allocation.forEach(item => {
                            resMsg.type = "message"
                            resMsg.message = "有什么可以帮到您的~~~"
                            resMsg.pid = data.token
                            delete resMsg.userList
                            fastify.storage.socket[item.id].send(JSON.stringify(resMsg));
                        });
                    }
                    return
                }
            }
            /* 消息处理 */
            if (data.type === "message") {
                /* 客服还未连接 存储问题内容*/
                if (data.pid === "-1") {
                    let item = fastify.storage.allocation.find(item => item.id === data.id)
                    item.list.push({
                        pid: item.id,
                        message: data.message
                    })
                    resMsg.type = "other"
                    fastify.storage.socket[data.id].send(JSON.stringify(resMsg));
                    return
                }
                let pid = data.pid
                let id = data.id
                data.pid = id
                data.id = pid
                fastify.storage.socket[pid].send(JSON.stringify(data));
                return
            }
        })
    });
}
/**
 * 随机min和max之间的值且包含min,max的值
 * @param { Number } min 最小值
 * @param { Number } max 最大值
 * @return { Number } 随机值
 */
function random(min, max) {
    return Math.round(Math.random() * (max - min) + min);
}
/**
 * 获取uuid字符串
 * @param { Number } len uuid长度
 * @return { String } uuid
 */
function UUID(len = 32) {
    const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
    const uuid = []
    let cLen = chars.length
    for (let i = 0; i < len; i++) {
        let r = random(0, cLen - 1);
        uuid.push(chars[(i == 19) ? (r & 0x3) | 0x8 : r])
    }
    let uLen = uuid.length
    let timeStr = String(new Date().getTime())
    for (let i = 0; i < timeStr.length; i++) {
        let r = random(0, uLen - 1);
        uuid[r] = timeStr[i]
    }
    return uuid.join('');
}
module.exports = socketCreate

2. 在index.js内添加调用该文件创建socket函数

image.png

服务端websocket业务功能说明

1. 监听到连接分配给该连接一个id,并存储该连接

2. 判断连接是否是用户还是客服

  • 是用户分配给当前接待客户最少的客服,如果当前没用客服在线,就存储当前客户等带客服上线
  • 是客服就查询当前是否有等待客户,存在就分配给当前客服,不存在就等待系统分配客户

服务端storage.js数据存储文件新添加连接的socket和等待的客户

image.png

客户端index.vue页面添加聊天业务代码

<template>
  <div class="index">
    <div class="main">
      <div class="userList" v-if="token">
        <div
          class="userItem"
          :class="data.pid === item.id ? 'userItemAct' : ''"
          v-for="item in data.userList"
          :key="item.id"
          @click="sel(item)"
        >
          <el-avatar :src="userAvatar" class="avatar" :size="30" />
          <div class="name">用户{{ item.id }}</div>
        </div>
      </div>
      <div class="centent">
        <div class="chat">
          <div class="chatTitle">
            {{ token ? "用户" : "客服" }}{{ data.pid === "-1" ? "" : data.pid }}
          </div>
          <div class="chatMain">
            <div v-for="(item, index) in data.list" :key="index">
              <div class="chatItem" v-if="item.pid">
                <el-avatar
                  :src="token ? userAvatar : serviceAvatar"
                  class="avatar"
                  :size="30"
                />
                <div class="chatText">{{ item.message }}</div>
              </div>
              <div class="chatItem chatItemR" v-if="!item.pid">
                <div class="chatText chatTextR">{{ item.message }}</div>
                <el-avatar
                  :src="token ? serviceAvatar : userAvatar"
                  class="avatar"
                  :size="30"
                />
              </div>
            </div>
          </div>
        </div>
        <div class="input">
          <el-input
            v-model="data.textarea"
            type="textarea"
            resize="none"
            autofocus
            class="textarea"
          />
          <div class="send">
            <el-button type="primary" @click="send">发送</el-button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import userAvatar from "@/assets/用户.png";
import serviceAvatar from "@/assets/客服.png";
import { inject } from "vue";
const app = inject("app");
let socket = null;
let token = app.storage.getToken();
const data = app.reactive({
  /* 发送文本 */
  textarea: "",
  /* 聊天数据 */
  list: [],
  /* 用户列表 */
  userList: [],
  /* 用户临时id */
  id: "",
  /* 对方id */
  pid: "",
});
/* 切换用户 */
function sel(item) {
  if (item.id === data.pid) return;
  data.pid = item.id;
  data.list = JSON.parse(JSON.stringify(item.list));
}
/* 发送消息 */
function send() {
  if (!data.textarea) return;
  socket.send(
    JSON.stringify({
      token,
      type: "message",
      message: data.textarea,
      id: data.id,
      pid: data.pid,
    })
  );
  data.list.push({ message: data.textarea });
  if (token) {
    let item = data.userList.find((item) => item.id === data.pid);
    item.list.push({ message: data.textarea });
  }
  data.textarea = "";
}
function onload() {
  /* 连接socket */
  socket = new WebSocket(process.env.VUE_APP_WS_URL);
  socket.onopen = () => {
    /* 链接后发送消息 */
    socket.send(JSON.stringify({ token, type: token ? "service" : "user" }));
  };
  socket.onerror = (err) => {
    console.log("链接失败:", err);
  };
  socket.onmessage = (e) => {
    let msg = JSON.parse(e.data);
    console.log("数据:", msg);
    /* 身份 */
    if (msg.type === "user") {
      data.id = msg.id;
      data.pid = msg.pid;
      data.list.push(msg);
      return;
    }
    /* 客服 */
    if (msg.type === "service") {
      data.id = token;
      if (!msg.userList.length) return;
      data.userList = msg.userList;
      data.pid = data.userList[0].id;
      data.list = JSON.parse(JSON.stringify(data.userList[0].list));
      return;
    }
     /* 添加用户 */
    if (msg.type === "addUser") {
      data.userList = [...data.userList,...msg.userList];
      return;
    }
    /* 消息 */
    if (msg.type === "message") {
      data.pid = msg.pid;
      data.list.push(msg);
      if (token) {
        let item = data.userList.find((item) => item.id === data.pid);
        item.list.push(msg);
      }
      return;
    }
    // 在这里写处理函数
  };
}

onload();
</script>

<style scoped>
.index {
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}
.main {
  width: 50vw;
  height: 80vh;
  border-radius: 20px;
  box-shadow: 2px 2px 20px rgba(0, 0, 0, 0.1);
  background: #fff;
  display: flex;
  overflow: hidden;
}

.centent {
  flex: 1;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
}
.chat {
  height: 72%;
  overflow: hidden;

  display: flex;
  flex-direction: column;
}
.chatTitle {
  padding: 10px 20px;

  text-align: right;
  font-weight: bold;
}
.chatMain {
  flex: 1;
  flex-shrink: 0;
  position: relative;
  overflow-y: auto;
  padding: 20px;
  background: #f9f9f9;
}
.chatMain::before {
  content: "";
  position: absolute;
  width: 100%;
  box-shadow: 0px 0px 20px 2px rgba(0, 0, 0, 0.2);
  left: 0;
  bottom: 0;
}
.chatItem {
  width: 100%;
  display: inline-flex;
  margin-bottom: 10px;
}
.chatText {
  margin-left: 10px;
  min-height: 30px;
  max-width: 85%;
  font-size: 14px;
  line-height: 20px;
  padding: 5px 10px;
  border-radius: 0 10px 10px 10px;
  background: #fff;
  word-break: break-all;
}
.chatItemR {
  justify-content: flex-end;
}
.chatTextR {
  background: #409eff !important;
  margin-left: 0 !important;
  margin-right: 10px;
  border-radius: 10px 0 10px 10px !important;
}

.input {
  flex: 1;
  flex-shrink: 0;
  padding: 10px;
  background: #f9f9f9;
  display: flex;
  flex-direction: column;
}
.send {
  padding-top: 10px;
  width: 100%;
  display: flex;
  justify-content: flex-end;
}
.textarea {
  flex: 1;
  flex-shrink: 0;
}

.userList {
  width: 22%;
  height: 100%;

  padding: 10px;
  overflow-y: auto;
}
.userItem {
  display: flex;
  align-items: center;
  padding: 10px;
  border-radius: 10px;
  background: #f9f9f9;
  margin-bottom: 10px;
  cursor: pointer;
}
.userItem:hover {
  background: #409eff !important;
}
.userItemAct {
  background: #409eff !important;
}
.name {
  margin-left: 10px;
  width: 80px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 14px;
}
.avatar {
  flex-shrink: 0;
  background: #fff;
}
</style>
<style>
textarea {
  height: 100% !important;
  width: 100% !important;
}
textarea {
  box-shadow: none !important;
}
</style>

完!!!!