From 626b428db14b9fde60e4ddf7870209fddba586f1 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 29 Jan 2026 03:20:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E6=89=8B=E5=8A=A8=E6=8E=A5=E5=85=A5=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 acceptSession 接口支持客服手动接入待分配会话 - 待接入列表显示"接入"按钮,点击后自动分配给当前客服 - 接入前检查客服会话数上限,防止超载 - 修复会话时长计算错误:Unix 时间戳需乘以 1000 转换为毫秒 Co-Authored-By: Claude Opus 4.5 --- application/admin/controller/Chat.php | 463 +++++++++++++++++++++++++ application/admin/view/chat/index.html | 69 +++- 2 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 application/admin/controller/Chat.php diff --git a/application/admin/controller/Chat.php b/application/admin/controller/Chat.php new file mode 100644 index 0000000..f44aa2a --- /dev/null +++ b/application/admin/controller/Chat.php @@ -0,0 +1,463 @@ +where('id', $admin_id)->field('login_token')->find(); + $login_token = $admin['login_token'] ?? ''; + + // 如果没有login_token,生成一个新的 + if (empty($login_token)) { + $login_token = md5($admin_id . time() . uniqid()); + Db::name('admin')->where('id', $admin_id)->update(['login_token' => $login_token]); + } + + // 获取客服配置 + $admin_status = Db::name('chat_admin_status')->where('admin_id', $admin_id)->find(); + if (!$admin_status) { + // 创建默认配置 + Db::name('chat_admin_status')->insert([ + 'admin_id' => $admin_id, + 'max_sessions' => 10, + 'is_enabled' => 1, + 'create_time' => time(), + 'update_time' => time(), + ]); + $admin_status = ['max_sessions' => 10, 'is_enabled' => 1]; + } + + $this->assign('admin_status', $admin_status); + $this->assign('admin_id', $admin_id); + $this->assign('login_token', $login_token); + + return $this->fetch(); + } + + /** + * 获取会话列表 (AJAX) + */ + public function sessions() + { + $user_info = Session::get('user_info'); + $admin_id = $user_info['id']; + $status = Request::instance()->get('status', 1); // 默认进行中 + + $where = ['admin_id' => $admin_id]; + if ($status !== 'all') { + $where['status'] = (int)$status; + } + + $sessions = Db::name('chat_session') + ->where($where) + ->order('last_msg_time desc, create_time desc') + ->select(); + + foreach ($sessions as &$session) { + // 获取用户信息 + $user = Db::name('user')->where('id', $session['user_id'])->find(); + $session['user_info'] = [ + 'username' => $user['username'] ?? '', + 'nickname' => $user['nickname'] ?? $user['username'] ?? '', + 'money' => $user['money'] ?? 0, + ]; + + // 未读消息数 + $session['unread_count'] = Db::name('chat_message') + ->where('session_id', $session['id']) + ->where('sender_type', 1) // 用户发送 + ->where('status', '<', 3) // 未读 + ->count(); + + // 格式化时间 + $session['create_time_fmt'] = date('Y-m-d H:i:s', $session['create_time']); + $session['last_msg_time_fmt'] = $session['last_msg_time'] ? date('H:i', $session['last_msg_time']) : ''; + + // 来源 + $sourceMap = [1 => 'PC', 2 => 'Game', 3 => 'Portal']; + $session['source_name'] = $sourceMap[$session['source']] ?? 'Unknown'; + } + + return json(['code' => 0, 'data' => $sessions]); + } + + /** + * 获取待分配会话列表 (AJAX) + */ + public function pending() + { + $sessions = Db::name('chat_session') + ->where('status', 0) // 待分配 + ->order('create_time asc') + ->select(); + + foreach ($sessions as &$session) { + $user = Db::name('user')->where('id', $session['user_id'])->find(); + $session['user_info'] = [ + 'username' => $user['username'] ?? '', + 'nickname' => $user['nickname'] ?? $user['username'] ?? '', + 'money' => $user['money'] ?? 0, + ]; + $session['create_time_fmt'] = date('Y-m-d H:i:s', $session['create_time']); + $sourceMap = [1 => 'PC', 2 => 'Game', 3 => 'Portal']; + $session['source_name'] = $sourceMap[$session['source']] ?? 'Unknown'; + } + + return json(['code' => 0, 'data' => $sessions]); + } + + /** + * 获取消息历史 (AJAX) + */ + public function messages() + { + $session_id = Request::instance()->get('session_id'); + $last_id = Request::instance()->get('last_id', 0); + $limit = Request::instance()->get('limit', 50); + + if (!$session_id) { + return json(['code' => 1, 'msg' => '参数错误']); + } + + $query = Db::name('chat_message')->where('session_id', $session_id); + if ($last_id > 0) { + $query->where('id', '<', $last_id); + } + + $messages = $query->order('id desc')->limit($limit)->select(); + $messages = array_reverse($messages); // 按时间正序 + + foreach ($messages as &$msg) { + $msg['create_time_fmt'] = date('H:i:s', $msg['create_time']); + } + + return json(['code' => 0, 'data' => $messages]); + } + + /** + * 结束会话 (AJAX) + */ + public function endSession() + { + $session_id = Request::instance()->post('session_id'); + $user_info = Session::get('user_info'); + + if (!$session_id) { + return json(['code' => 1, 'msg' => '参数错误']); + } + + $session = Db::name('chat_session')->where('id', $session_id)->find(); + if (!$session || $session['admin_id'] != $user_info['id']) { + return json(['code' => 1, 'msg' => '无权操作']); + } + + Db::name('chat_session')->where('id', $session_id)->update([ + 'status' => 2, + 'end_time' => time(), + 'update_time' => time(), + ]); + + insertAdminLog('结束客服会话', '会话ID: ' . $session_id); + + return json(['code' => 0, 'msg' => '操作成功']); + } + + /** + * 手动接入会话 (AJAX) + */ + public function acceptSession() + { + $session_id = Request::instance()->post('session_id'); + $user_info = Session::get('user_info'); + $admin_id = $user_info['id']; + + if (!$session_id) { + return json(['code' => 1, 'msg' => '参数错误']); + } + + // 检查会话是否存在且状态为待分配 + $session = Db::name('chat_session')->where('id', $session_id)->find(); + if (!$session) { + return json(['code' => 1, 'msg' => '会话不存在']); + } + if ($session['status'] != 0) { + return json(['code' => 1, 'msg' => '该会话已被接入']); + } + + // 检查客服当前会话数是否已满 + $admin_status = Db::name('chat_admin_status')->where('admin_id', $admin_id)->find(); + $max_sessions = $admin_status['max_sessions'] ?? 10; + $current_sessions = Db::name('chat_session') + ->where('admin_id', $admin_id) + ->where('status', 1) + ->count(); + + if ($current_sessions >= $max_sessions) { + return json(['code' => 1, 'msg' => '已达最大会话数限制(' . $max_sessions . ')']); + } + + // 接入会话 + $result = Db::name('chat_session')->where('id', $session_id)->where('status', 0)->update([ + 'admin_id' => $admin_id, + 'status' => 1, + 'update_time' => time(), + ]); + + if ($result) { + insertAdminLog('接入客服会话', '会话ID: ' . $session_id); + return json(['code' => 0, 'msg' => '接入成功']); + } else { + return json(['code' => 1, 'msg' => '接入失败,可能已被其他客服接入']); + } + } + + /** + * 转接会话 (AJAX) + */ + public function transfer() + { + $session_id = Request::instance()->post('session_id'); + $target_admin_id = Request::instance()->post('target_admin_id'); + $user_info = Session::get('user_info'); + + if (!$session_id || !$target_admin_id) { + return json(['code' => 1, 'msg' => '参数错误']); + } + + $session = Db::name('chat_session')->where('id', $session_id)->find(); + if (!$session || $session['admin_id'] != $user_info['id']) { + return json(['code' => 1, 'msg' => '无权操作']); + } + + Db::name('chat_session')->where('id', $session_id)->update([ + 'admin_id' => $target_admin_id, + 'update_time' => time(), + ]); + + insertAdminLog('转接客服会话', '会话ID: ' . $session_id . ', 转接给: ' . $target_admin_id); + + return json(['code' => 0, 'msg' => '转接成功']); + } + + /** + * 获取在线客服列表 (AJAX) + */ + public function onlineAgents() + { + $user_info = Session::get('user_info'); + + // 获取启用客服功能的管理员 + $agents = Db::name('chat_admin_status') + ->alias('s') + ->join('admin a', 'a.id = s.admin_id') + ->where('s.is_enabled', 1) + ->where('a.id', '<>', $user_info['id']) + ->field('a.id, a.admin as username, a.admin as nickname, s.max_sessions') + ->select(); + + return json(['code' => 0, 'data' => $agents]); + } + + /** + * 聊天记录查询 + */ + public function record() + { + $get = Request::instance()->get(); + $this->assign('get', $get); + + $username = Request::instance()->get('username'); + $admin_id = Request::instance()->get('admin_id'); + $startDate = Request::instance()->get('startDate'); + $endDate = Request::instance()->get('endDate'); + $export = Request::instance()->get('export'); + + $where = []; + $startTime = 0; + $endTime = time(); + + if ($startDate) $startTime = strtotime($startDate); + if ($endDate) $endTime = strtotime($endDate) + 86400; + $where['s.create_time'] = ['between', [$startTime, $endTime]]; + + if ($admin_id) { + $where['s.admin_id'] = $admin_id; + } + + $query = Db::name('chat_session') + ->alias('s') + ->join('user u', 'u.id = s.user_id', 'left') + ->join('admin a', 'a.id = s.admin_id', 'left') + ->where($where); + + if ($username) { + $query->where('u.username', 'like', "%{$username}%"); + } + + if ($export == 1) { + $list = $query->field('s.*, u.username, u.nickname as user_nickname, a.admin as admin_username') + ->order('s.create_time desc') + ->select(); + + $excelData = []; + foreach ($list as $k => $v) { + $excelData[$k][0] = $v['id']; + $excelData[$k][1] = $v['username']; + $excelData[$k][2] = $v['admin_username'] ?? '未分配'; + $excelData[$k][3] = ['PC', 'Game', 'Portal'][$v['source'] - 1] ?? ''; + $excelData[$k][4] = ['待分配', '进行中', '已结束'][$v['status']] ?? ''; + $excelData[$k][5] = $v['rating'] ?? '-'; + $excelData[$k][6] = date('Y-m-d H:i:s', $v['create_time']); + $excelData[$k][7] = $v['end_time'] ? date('Y-m-d H:i:s', $v['end_time']) : '-'; + } + $title = ['会话ID', '用户名', '客服', '来源', '状态', '评分', '开始时间', '结束时间']; + $this->exportExcelCore($excelData, '聊天记录-' . date('Ymd'), $title); + exit; + } + + $list = $query->field('s.*, u.username, u.nickname as user_nickname, a.admin as admin_username') + ->order('s.create_time desc') + ->paginate(15, false, ['query' => $get]); + + // 获取客服列表 + $admins = Db::name('admin')->where('status', 1)->field('id, admin as username, admin as nickname')->select(); + + $this->assign('list', $list); + $this->assign('admins', $admins); + + return $this->fetch(); + } + + /** + * 查看会话详情 + */ + public function detail() + { + $session_id = Request::instance()->get('session_id'); + + $session = Db::name('chat_session') + ->alias('s') + ->join('user u', 'u.id = s.user_id', 'left') + ->join('admin a', 'a.id = s.admin_id', 'left') + ->where('s.id', $session_id) + ->field('s.*, u.username, u.nickname as user_nickname, u.money, a.admin as admin_username') + ->find(); + + if (!$session) { + $this->error('会话不存在'); + } + + $messages = Db::name('chat_message') + ->where('session_id', $session_id) + ->order('id asc') + ->select(); + + $this->assign('session', $session); + $this->assign('messages', $messages); + + return $this->fetch(); + } + + /** + * 客服统计 + */ + public function stats() + { + $user_info = Session::get('user_info'); + $admin_id = Request::instance()->get('admin_id', $user_info['id']); + $startDate = Request::instance()->get('startDate', date('Y-m-d', strtotime('-7 days'))); + $endDate = Request::instance()->get('endDate', date('Y-m-d')); + + $startTime = strtotime($startDate); + $endTime = strtotime($endDate) + 86400; + + $where = [ + 'admin_id' => $admin_id, + 'create_time' => ['between', [$startTime, $endTime]], + ]; + + // 统计数据 + $stats = [ + 'total_sessions' => Db::name('chat_session')->where($where)->count(), + 'ended_sessions' => Db::name('chat_session')->where($where)->where('status', 2)->count(), + 'avg_rating' => Db::name('chat_session')->where($where)->whereNotNull('rating')->avg('rating') ?: 0, + 'total_messages' => Db::name('chat_message') + ->alias('m') + ->join('chat_session s', 's.id = m.session_id') + ->where('s.admin_id', $admin_id) + ->where('m.create_time', 'between', [$startTime, $endTime]) + ->count(), + ]; + + $this->assign('stats', $stats); + $this->assign('startDate', $startDate); + $this->assign('endDate', $endDate); + + return $this->fetch(); + } + + /** + * 获取快捷回复列表 (AJAX) + */ + public function quickReplies() + { + $category = Request::instance()->get('category'); + + $query = Db::name('chat_quick_reply')->where('status', 1); + if ($category) { + $query->where('category', $category); + } + + $list = $query->order('sort asc, id asc')->select(); + $categories = Db::name('chat_quick_reply') + ->where('status', 1) + ->whereNotNull('category') + ->where('category', '<>', '') + ->group('category') + ->column('category'); + + return json(['code' => 0, 'data' => $list, 'categories' => $categories]); + } + + /** + * 图片上传 + */ + public function upload() + { + $file = Request::instance()->file('image'); + if (!$file) { + return json(['code' => 1, 'msg' => '请选择文件']); + } + + // 验证文件 + $info = $file->validate([ + 'size' => 2097152, // 2MB + 'ext' => 'jpg,png,gif,jpeg' + ])->move(ROOT_PATH . 'public/uploads/chat/' . date('Ymd')); + + if ($info) { + $url = '/uploads/chat/' . date('Ymd') . '/' . $info->getSaveName(); + return json(['code' => 0, 'url' => $url]); + } else { + return json(['code' => 1, 'msg' => $file->getError()]); + } + } +} diff --git a/application/admin/view/chat/index.html b/application/admin/view/chat/index.html index 923d4c2..8327cfe 100644 --- a/application/admin/view/chat/index.html +++ b/application/admin/view/chat/index.html @@ -162,6 +162,21 @@ color: #ddd; margin-bottom: 10px; } +.accept-btn { + padding: 4px 10px; + background: #009688; + color: #fff; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; + margin-left: 8px; + transition: background 0.2s; +} +.accept-btn:hover { + background: #00796b; +} /* 中间聊天区域 */ .chat-main { @@ -766,7 +781,7 @@ layui.use(['layer', 'upload'], function(){ $.get('/chat/sessions', {status: 1}, function(res) { if (res.code === 0) { $('#active-count').text(res.data.length); - renderSessionList(res.data, '#session-list'); + renderSessionList(res.data, '#session-list', false); } }); } @@ -776,13 +791,13 @@ layui.use(['layer', 'upload'], function(){ $.get('/chat/pending', function(res) { if (res.code === 0) { $('#pending-count').text(res.data.length); - renderSessionList(res.data, '#pending-list'); + renderSessionList(res.data, '#pending-list', true); } }); } // 渲染会话列表 - function renderSessionList(sessions, container) { + function renderSessionList(sessions, container, isPending) { var html = ''; if (sessions.length === 0) { html = '
'; @@ -803,20 +818,57 @@ layui.use(['layer', 'upload'], function(){ html += '
'; html += '
'; html += ' ' + username + ''; - html += ' ' + (session.last_msg_time_fmt || '') + ''; + html += ' ' + (session.last_msg_time_fmt || session.create_time_fmt || '') + ''; html += '
'; html += '
' + lastMsg + '
'; html += '
'; - html += unreadBadge; + if (isPending) { + html += ' '; + } else { + html += unreadBadge; + } html += '
'; }); } $(container).html(html); // 绑定点击事件 - $(container + ' .session-item').click(function() { + $(container + ' .session-item').click(function(e) { + // 如果点击的是接入按钮,不触发打开会话 + if ($(e.target).hasClass('accept-btn')) return; var sessionId = $(this).data('id'); - openSession(sessionId); + if (!isPending) { + openSession(sessionId); + } + }); + + // 绑定接入按钮事件 + if (isPending) { + $(container + ' .accept-btn').click(function(e) { + e.stopPropagation(); + var sessionId = $(this).data('id'); + acceptSession(sessionId); + }); + } + } + + // 接入会话 + function acceptSession(sessionId) { + $.post('/chat/acceptSession', {session_id: sessionId}, function(res) { + if (res.code === 0) { + layer.msg('接入成功', {icon: 1, time: 1500}); + loadSessions(); + loadPendingSessions(); + // 自动打开刚接入的会话 + setTimeout(function() { + openSession(sessionId); + // 切换到进行中标签 + $('.sessions-tabs .tab-item[data-status="1"]').click(); + }, 500); + } else { + layer.msg(res.msg || '接入失败', {icon: 2}); + loadPendingSessions(); + } }); } @@ -877,7 +929,8 @@ layui.use(['layer', 'upload'], function(){ // 会话时长计时器 function startDurationTimer(startTime) { if (durationTimer) clearInterval(durationTimer); - sessionStartTime = new Date(startTime).getTime(); + // startTime 是 Unix 时间戳(秒),需要转换为毫秒 + sessionStartTime = startTime * 1000; function updateDuration() { var now = Date.now();