批改状态:合格
老师批语:标题可以适当的简洁些!
都是复习, DOM操作, js,jQuery操作等. 基本实现西门老师上课讲的功能内容.
cURL的使用, 基础课没教, 在这里卡了蛮久, 需要找时间学习.
切换客户聊天记录容器: 使用类似朱老师教的”页签菜单切换”demo.
客服端刷新后, 找回正在沟通的客户: 客户端在创建链接前, 强制先输入手机号. 客服端通过后台管理系统, 用账号登录. 这样就可以用手机号或客服账号做客户连接对象全局数组/客服连接对象全局数组的key. 同时连接对象也设置属性identity, 为其赋值客户手机号或客服账号.
sendId值为客服账号的连接属性, 将其identity(即手机号)发送消息给客服页面, 客服页面遍历手机号数组, 把这些客户重新添加回客户列表. 然后以客户手机号, 客服账号做查询条件, 使用cURL模拟想laravel发送GET请求, 通过laravel从数据库中查询聊天记录, 并渲染回各个客户的”聊天页签”中(回填聊天记录没做了, 懒…).提供websocket通信的服务器(也就是warkerman的应用服务器), 使用cURL模拟发送POST请求到laravel, 通过laravel的控制器方法操作数据库, 把聊天记录保存到数据库.
VerifyCsrfToken中间件中, 把定位到保存聊天记录到数据库的控制器方法的路由加入到$except属性数组中, 即, 告诉laravel, 这个路由的post请求就不用验证token了.1- 客服端界面
2- 客户端界面
3- 客服小姐姐分配到客户时的客服端界面
4- 客户端收发消息
5-客服端收发消息


warkerman搭建websocket通信服务器
<?phpuse Workerman\Worker;require_once __DIR__ . '/workerman/Autoloader.php';// 加载发送请求的方法require_once 'save_msg.php';// 注意:这里与上个例子不同,使用的是websocket协议$ws_worker = new Worker("websocket://0.0.0.0:2000");// 启动4个进程对外提供服务$ws_worker->count = 4;/* 创建全局连接数组,key=连接的id,val=连接; 因为存在客户可能刷新浏览器页面, 触发websocket给同一个客户再分配一个新的连接id* 所以, 一般是用客户保存在数据表中的用户id来做key, 每次刷新, 用户id都跟最新分配的连接关联, 就能解决用户刷新的问题了.* 用户id从哪来? 可以在登录成功后把用户id保存到cookie中.* 另一种方案: 把$conns链接数组保存到缓存(如: Redis)中, 同样用用户id做key, 序列化/json格式化的连接做value.*//** 新版本: key改为客户在前端录入的手机号, 使用消息数组中key为from_id的元素存储;* 客服数组的key改为管理员账号, 同样用from_id存储*/// 客户连接数组$customerConns = [];// 客服连接数组$servicerConns = [];// 发送约定格式的消息数组(转换成json)function sendMessage($conn, $from_id, $type, $msg, $custom_id=null) {$sendInfo['from_id'] = $from_id;$sendInfo['type'] = $type;$sendInfo['msg'] = $msg;if($custom_id != null) {$sendInfo['custom_id'] = $custom_id;}$conn->send(json_encode($sendInfo));}function saveMessage($from, $to, $msg) {$msgInfo['from'] = $from;$msgInfo['to'] = $to;$msgInfo['msg'] = $msg;$msgInfo['send_time'] = time();saveMsg('cms.com/admin/servicer/savemsg', $msgInfo);}// 当收到客户端发来的数据后返回hello $data给客户端$ws_worker->onMessage = function($connection, $data){global $customerConns;global $servicerConns;// 分辨用户类型, 只能客户和客服之间通信.// 判断客户端模拟用户登录状态的标识type, 若值为login标识登录成功. 则把用户的类型(客户/客服)// 以自定义属性的方式设置到$connection(即连接)中// json->数组$data = json_decode($data, true);if($data['type'] == 'login') {var_dump($connection->id);// 给连接动态加入group属性,标识[客户]和[客服]$connection->group = $data['group'];// 改用客户前端录入的手机号/客服小姐姐的登录账号作为连接的识别码, 不用$connection->id了$connection->identity = $data['from_id'];// 模拟"登录"的连接,放入到对应连接数组中if($data['group'] == 'admin') {// admin标识为[客服]var_dump('客服小姐姐' . $data['from_id'] . '上线了, 其分配到的链接id为: ' . $connection->id);// 改用客服小姐姐的登录账号作为key// $servicerConns[$connection->id] = $connection;$servicerConns[$data['from_id']] = $connection;// 遍历客户连接全局数组, 找到sendId为当前客服小姐姐的客户链接, 返回给这个客服小姐姐$customPhoneNumbers = [];foreach($customerConns as $phoneNumber => $customerConn) {if(!empty($customerConn->sendId) && $customerConn->sendId == $connection->identity) {$customPhoneNumbers[] = $phoneNumber;}}// 找到这位客服小姐姐服务的客户if(count($customPhoneNumbers) > 0) {sendMessage($connection, 'system', 'login', '继续为[' . implode(',', $customPhoneNumbers) . ']服务吧', $customPhoneNumbers);}} else {// member标识为[客户]// DEL: 改用用户在前端录入的手机号作为key// $customerConns[$connection->id] = $connection;$customerConns[$data['from_id']] = $connection;// 如果是客户登录, 还需要给他安排一个客服(array_rand()函数随机返回数组元素的key值)$connection->sendId = array_rand($servicerConns, 1);// 没有小姐姐可供分配// if(!is_numeric($connection->sendId)) {if(empty($connection->sendId)) {sendMessage($connection, 'system', 'msg', '暂无客服小姐姐在线, 请稍后刷新重试');var_dump('暂无客服小姐姐');return;}var_dump('给手机号为'.$connection->identity.'的客户(链接id: '.$connection->id.')分配的是'.$connection->sendId.'客服小姐姐(链接id: '.$servicerConns[$connection->sendId]->id.')');// 通知这位客服, 有新客户进来$target = $servicerConns[$connection->sendId];sendMessage($target, 'system', 'login', '有新客户登录, 电话号码为: ' . $connection->identity, $connection->identity);}} else if($data['type'] == 'msg') {// 模拟"发送数据"的连接// 判断发送数据的连接是[客服]还是[客户]if($connection->group == 'member') {// 客户// if(!is_numeric($connection->sendId)) {if(empty($connection->sendId)) {sendMessage($connection, 'system', 'msg', '为您服务的小姐姐可能已掉线, 请刷新');var_dump('暂无客服小姐姐');return;}// 获取在客户登录时指定的客服连接$target = $servicerConns[$connection->sendId];var_dump('客户' .$connection->identity . '给账号为' . $connection->sendId . '的客服小姐姐发信息');sendMessage($target, $connection->identity, 'msg', $data['msg']);// 保存消息到数据库saveMessage($connection->identity, $target->identity, $data['msg']);} else if($connection->group == 'admin') {// 客服// 跟谁说话$phoneNumber = $data['sendId'];if(empty($phoneNumber) || !isset($customerConns[$phoneNumber])) {sendMessage($connection, 'system', 'msg', '该用户不存在, 可能已下线');return;}// 要跟其说话的客户的链接对象$target = $customerConns[$phoneNumber];sendMessage($target, $connection->identity, 'msg', $data['msg']);saveMessage($connection->identity, $target->identity, $data['msg']);var_dump('客服' .$connection->identity . '给手机号为' . $phoneNumber . '的客户发信息');}}};// 当有连接断开时$ws_worker->onClose = function($connection) {global $servicerConns;global $customerConns;if($connection->group == 'admin') {// 客服断开$unconnId = $connection->identity;//$connection->id;// 把断开的客服连接移除出客服连接数组unset($servicerConns[$connection->id]);var_dump('客服' . $unconnId . '下线了');// 客服断开, 需要给该客服负责的客户重新分配新客服foreach($customerConns as $customerConn) {if($customerConn->sendId == $unconnId) {// 该客服小姐姐负责的客户$customerConn->sendId = array_rand($servicerConns, 1);// 没分配到客服小姐姐, 直接结束分配.if(empty($customerConn->sendId)) {sendMessage($connection, 'system', 'msg', '坏了, 客服小姐姐的网络开小差了, 请稍后刷新重试吧');var_dump('当前没有客服小姐姐在线');continue;}var_dump('重新给手机号为'.$customerConn->identity.'的客户(链接id: '.$customerConn->id.')分配的是'.$customerConn->sendId.'客服小姐姐(链接id: '.$servicerConns[$customerConn->sendId]->id.')');// 新分配的客服小姐姐的连接$target = $servicerConns[$customerConn->sendId];// 给新分配到的客服小姐姐发分配消息sendMessage($target, 'system', 'login', '有新客户登录, id为:' . $customerConn->identity, $customerConn->identity);}}} else {// 客户断开// 断开的客户连接id$unconnId = $connection->identity;var_dump('客户' . $unconnId . '下线了');// 负责该客户的客服小姐姐id$servicerId = $connection->sendId;// 把断开的客户连接移除出客户连接数组unset($customerConns[$connection->identity]);if(empty($servicerId)) {// 判断断开的客户是否有客服接待, 没有, 则直接断开客户连接;return;}// 客户断开, 系统给负责该客户的客服发消息, 不必再负责该客户$target = $servicerConns[$servicerId];sendMessage($target, 'system', 'logout', '客户' . $unconnId . '跟你说了声拜拜后, 下线了', $unconnId);}};// 运行workerWorker::runAll();
cURL模拟发送保存聊天记录的脚本
<?phpfunction saveMsg($url, $data) {// $header = ["Content-Type:application/x-www-form-urlencoded", "token:test", "client:h5"];$header = ["Content-Type:application/json", "token:test", "client:h5"];$res = curlPost($url, $data, 5, $header, 'json');var_dump($res);return $res;}/*** 传入数组进行HTTP POST请求*/function curlPost($url, $post_data = array(), $timeout = 5, $header = "", $data_type = "") {$header = empty($header) ? '' : $header;//支持json数据数据提交if($data_type == 'json'){$post_string = json_encode($post_data);}elseif($data_type == 'array') {$post_string = $post_data;}elseif(is_array($post_data)){$post_string = http_build_query($post_data, '', '&');}var_dump($post_string);$ch = curl_init(); // 启动一个CURL会话curl_setopt($ch, CURLOPT_URL, $url); // 要访问的地址curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 对认证证书来源的检查 // https请求 不验证证书和hostscurl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在// curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']); // 模拟用户使用的浏览器//curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1); // 使用自动跳转curl_setopt($ch, CURLOPT_AUTOREFERER, 1); // 自动设置Referercurl_setopt($ch, CURLOPT_POST, true); // 发送一个常规的Post请求curl_setopt($ch, CURLOPT_POSTFIELDS, $post_string); // Post提交的数据包curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout); // 设置超时限制防止死循环curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);//curl_setopt($curl, CURLOPT_HEADER, 0); // 显示返回的Header区域内容curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // 获取的信息以文件流的形式返回curl_setopt($ch, CURLOPT_HTTPHEADER, $header); //模拟的header头$result = curl_exec($ch);// 打印请求的header信息//$a = curl_getinfo($ch);//var_dump($a);curl_close($ch);return $result;}
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>"百度商桥"客服端</title><link rel="stylesheet" href="/static/plugin/layui/css/layui.css" media="all"><script src="/static/plugin/layui/layui.js"></script><script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script><style>* {padding: 0;margin: 0;box-sizing: border-box;}body {padding: 10px;width: 100vw;height: 100vh;background-color: #f0f0f0;display: grid;gap: 10px;grid-template-columns: 240px auto;grid-template-rows: 3fr 2fr;grid-template-areas:"customer-list message-list""customer-list message-send";}.customer-list {grid-area: customer-list;background-color: white;border: 1px solid #ccc;}.message-list {grid-area: message-list;background-color: white;overflow-y: auto;padding: 10px;display: grid;grid-template-rows: 50px auto;}.message-list > .system-message {height: 40px;width: 100%;background-color: wheat;border: 1px solid #eaeaea;margin-bottom: 10px;border-radius: 5PX;overflow-y: hidden;}.message-list > .private-chat-box {background-color: #DFECEC;border-radius: 5px;}.show {display: block;}.hide {display: none;}.message-send {display: grid;grid-auto-rows: auto 40px;grid-area: message-send;background-color: white;padding: 10px;}.message-send > .message-input-area {padding: 5px;background-color: #fafafa;border: 1px solid #ccc;margin-bottom: 10px;border-radius: 5px;outline: none;overflow-y: auto;}.message-send > .message-input-area:hover {box-shadow: 0px 0px 1px #333;}.message-send > .btn-area {width: 100%;text-align: right;}.msg-box-friend {padding: 10px;width: 75%;margin: 5px auto 0 5px;background-color: lightblue;border: 1px solid #ccc;border-radius: 5px;/* overflow-x: wordwrap; */white-space:normal;}.msg-box-me {padding: 10px;width: 75%;margin: 5px 5px 0 auto;background-color: wheat;border: 1px solid #ccc;border-radius: 5px;/* overflow-x: wordwrap; */white-space:normal;}.customer-item {border: 1px solid #e0e0e0;border-radius: 5px;margin: 5px;padding: 5px 10px;}.active {background-color: skyblue;}.bling {color: red;background-color: linen;}</style></head><body><input type="hidden" name="username" value="{{$username}}"><!-- 客户列表 --><div class="customer-list"></div><!-- 消息记录 --><div class="message-list"><div class="system-message"></div><!-- <div class="private-chat-box"></div> --></div><!-- 发送消息 --><div class="message-send"><div class="message-input-area" contenteditable="true"></div><div class="btn-area"><span class="layui-btn layui-btn-success" onclick="send()">发送</span></div></div></body><script>layui.use(['layer'], function() {layer = layui.layer;$ = layui.jquery;});// 假设服务端ip为127.0.0.1ws = new WebSocket("ws://127.0.0.1:2000");/* 当客户端连通服务器端的时候 */ws.onopen = function() {// 当客户端连通服务端时, 把当前客户端的用户标识(客户/客服)发给服务端var data = {};// js对象的属性可以自定义. type: login, 标识用户行为为声明登录;type: msg, 标识用户行为为发送消息。data.type = 'login';// 约定admin代表登录的用户是客服data.group = 'admin';// 使用登录账号作为客服小姐姐的登录标识(因为链接id在刷新或关闭浏览器后值会改变)data.from_id = $('input[name="username"]').val();// 发送JSON格式的数据ws.send(JSON.stringify(data));};ws.onmessage = function(e) {var data = JSON.parse(e.data);// alert("收到服务端的消息:" + e.data);// 系统发来的消息, 表示有新客户接入, 并分配到当前客服小姐姐if(data.from_id == 'system' && data.type == 'login') {if(!Array.isArray(data.custom_id)) {addOldCustomer(data.custom_id);} else {data.custom_id.forEach(function(item) {addOldCustomer(item);});}// 发送客户接入的系统消息var str = "<span style='margin-right: 20px; color: red;'>系统消息: "+data.msg+"</span>";$('.system-message').prepend($(str));} else if(data.from_id == 'system' && data.type == 'logout') {// 客户下线// 客户列表移除客户$('.customer-item[data-id="'+data.custom_id+'"]').remove();// 聊天记录窗口移除聊天容器$('.private-chat-box[data-id="'+data.custom_id+'"]').remove();// 系统消息走一波// 发送客户接入的系统消息var str = "<span style='margin-right: 20px;'>系统消息: 客户"+data.custom_id+"下线了</span>";$('.system-message').prepend($(str));} else if(data.type = 'msg') {// 客户给客服发消息// 客户列表中的客户项背景色变一下再还原// 客户列表中的当前客户项var customerItem = $('.customer-item[data-id="'+data.from_id+'"]');blingbling(customerItem);// 获取正在聊天的窗口的data-id值var chatting_id = $('.private-chat-box').filter('.show').data('id');// 当前没有正在聊天的窗口, 则显示跟发送消息的客户的聊天窗口if(chatting_id == undefined) {// 显示聊天对话框var chatbox = $('.private-chat-box').filter('[data-id="'+data.from_id+'"]');// chatbox.removeClass('hide').addClass('show');// 相当于点击当前发送消息的客户的客户向var customItem = $('.customer-item[data-id="'+data.from_id+'"]')talkTo(customerItem[0]);// 显示客户讲的消息var str = "<div class='msg-box-friend'>"+data.from_id+"说: "+data.msg+"</div>";$(str).appendTo(chatbox);} else if(chatting_id != data.from_id) {// 正在聊天的客户不是当前发送消息的客户// 把消息插入跟当前客户的聊天窗口var str = "<div class='msg-box-friend'>"+data.from_id+"说: "+data.msg+"</div>";$(str).appendTo($('.private-chat-box').filter('[data-id="'+data.from_id+'"]'));// 在客户列表中当前发送消息的客户项右边加上未读标签数量// 客户项中的未读消息数量框var msgCount = customerItem.find('.layui-badge');// 如果没有, 表示之前没有未读消息, 加上即可if(msgCount == undefined || msgCount.length < 1) {$('<span class="layui-badge">1</span>').appendTo(customerItem);} else {// 有, 未读消息加一var count = parseInt(msgCount.html()) + 1;msgCount.html(count);}} else if(chatting_id == data.from_id) {// 当前聊天窗口就是当前发送消息的客户// 逻辑可以直接用chatting_id是undefined的逻辑, 后期考虑合并// 显示聊天对话框var chatbox = $('.private-chat-box').filter('[data-id="'+data.from_id+'"]');chatbox.removeClass('hide').addClass('show');console.log(chatbox);// 显示客户讲的消息var str = "<div class='msg-box-friend'>"+data.from_id+"说: "+data.msg+"</div>";$(str).appendTo(chatbox);}}};// 客服刷新网页, 找回正在沟通的客户列表function addOldCustomer(custom_id) {// 把新登录的客户加到客户列表区var customer = $('<div class="customer-item" data-id="'+custom_id+'" onclick="talkTo(this)">客户'+custom_id+'</div>');customer.appendTo('.customer-list');// 闪一下blingbling(customer);// 在消息记录容器(message-list)中插入跟登录的客户聊天的对话框divvar privateChatBox = '<div class="private-chat-box hide" data-id="'+custom_id+'"></div>';$(privateChatBox).appendTo('.message-list');}// 客户上线/有未读消息/有新消息, 变一下色, 2秒后复原function blingbling(jqele) {jqele.addClass('bling');setTimeout(() => {jqele.removeClass('bling');}, 2000);}// 点击客户列表区, 选择要聊天的客户对象function talkTo(ele) {$(ele).siblings().removeClass('active');$(ele).addClass('active');// 显示聊天窗口var dataId = $(ele).data('id');$('.private-chat-box').removeClass('show').addClass('hide');$('.private-chat-box[data-id="'+dataId+'"]').removeClass('hide').addClass('show');// 移除未读消息$(ele).find('.layui-badge').remove();}function send() {var str = "<div class='msg-box-me'>我说: "+$('.message-input-area').html()+"</div>";var data = {};// 标识此次发送的数据是发送消息data.type='msg';// 标识是从客服端发的data.group = 'admin'// 获取当前选中的客户var customer_id = $('div[class*=active]').data('id');if(isNaN(customer_id)) {return layer.alert('请先选中一个客户, 再发送消息.');}data.sendId = customer_id;// 要发送的消息data.msg = $('.message-input-area').html();$(str).appendTo('.message-list > .private-chat-box.show');// 转成json字符串发送。ws.send(JSON.stringify(data));$('.message-input-area').html('');}</script></html>
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>"百度商桥"客户端</title><link rel="stylesheet" href="/static/plugin/layui/css/layui.css" media="all"><script src="/static/plugin/layui/layui.js"></script><script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {background-color: #fafafa;}.im {width: 400px;height: 520px;background-color: wheat;margin: 40px auto;padding: 10px;box-shadow: 0 0 5px #999;}.im .history {background-color: white;border: 1px solid #aaa;width: 100%;height: 290px;padding: 5px;overflow-y: auto;border-radius: 5px;}.im .inputing {background-color: white;border: 1px solid #aaa;width: 100%;height: 150px;margin-top: 10px;padding: 5px;outline: none;border-radius: 5px;}.im .btn-area {width: 100%;text-align: right;margin-top: 10px;}.msg-box-friend {padding: 10px;width: 75%;margin: 5px auto 0 5px;background-color: lightblue;border: 1px solid #ccc;border-radius: 5px;/* overflow-x: wordwrap; */white-space:normal;}.msg-box-me {padding: 10px;width: 75%;margin: 5px 5px 0 auto;background-color: wheat;border: 1px solid #ccc;border-radius: 5px;/* overflow-x: wordwrap; */white-space:normal;}</style></head><body><input type="hidden" name="phone_number" value=""><div class="im"><div class="history"></div><!-- contenteditable="true", 这个div就可编辑了 --><div class="inputing" contenteditable="true"></div><div class="btn-area"><span class="layui-btn layui-btn-success" onclick="send()">发送</span></div></div></body><script>layui.use(['layer'], function() {layer = layui.layer;$ = layui.jquery;// 在layer加载完成后再弹出, 否则无法上下左右居中显示弹出框.layer.ready(function() {// 用户填入手机号getPhoneNumber();});});function getPhoneNumber() {layer.prompt({formType: 0,title: '请输入你的手机号',area: ['800px', '350px'],btn: ['确定']}, function(value, index, elem) {// 验证是否是有效地手机号码if(!(/^1[3456789]\d{9}$/.test(value))){return layer.alert("手机号码有误,请重填");}// 保存电话号码到隐藏域$("input[name='phone_number']").val(value);// 连接websocket服务器connect(value);layer.close(index);});}function connect(value) {// 假设服务端ip为127.0.0.1ws = new WebSocket("ws://127.0.0.1:2000");/* 当客户端连通服务器端的时候 */ws.onopen = function() {// 当客户端连通服务端时, 把当前客户端的用户标识(客户/客服)发给服务端var data = {};// js对象的属性可以自定义. type: login, 标识用户行为为声明登录;type: msg, 标识用户行为为发送消息。data.type = 'login';// 约定admin表示登录的用户是客户data.group = 'member';// 识别客户的唯一码从连接id改为用户手机号, 因为刷新后, 连接id就会变.data.from_id = value;// 发送JSON格式的数据ws.send(JSON.stringify(data));};// 从服务器端获取到消息时ws.onmessage = function(e) {// alert("收到服务端的消息:" + e.data);var receive = JSON.parse(e.data);var str = "<div class='msg-box-friend'>"+receive.from_id+"说: "+receive.msg+"</div>";$(str).appendTo('.history');};}function send() {// 消息框var str = "<div class='msg-box-me'>我说: "+$('.inputing').html()+"</div>";var data = {};// 标识此次发送的数据是发送消息data.type='msg';// 标识是从客服端发的data.group = 'admin'// 标识私聊的对象id,0标识群发. DEL_客户连接时由系统随机分配客服小姐姐, 不需要手动指定了// data.sendId = 0;// 要发送的消息data.msg = $('.inputing').html();// 在消息历史中显示发送的消息$(str).appendTo('.history');// 发送JSON格式的数据ws.send(JSON.stringify(data));$('.inputing').html('');}</script></html>
public function saveMsg(Request $req) {$msg = $req->all();DB::table('msg_list')->insert($msg);return json_encode(['status' => 0, 'message' => '保存成功']);}
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号