feat(admin): 添加客服手动接入会话功能并修复时长计算

- 新增 acceptSession 接口支持客服手动接入待分配会话
- 待接入列表显示"接入"按钮,点击后自动分配给当前客服
- 接入前检查客服会话数上限,防止超载
- 修复会话时长计算错误:Unix 时间戳需乘以 1000 转换为毫秒

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
li 2026-01-29 03:20:17 +08:00
parent a49f5e8de6
commit 626b428db1
2 changed files with 524 additions and 8 deletions

View File

@ -0,0 +1,463 @@
<?php
namespace app\admin\controller;
use think\Db;
use think\Request;
use think\Session;
/**
* 客服管理控制器
* Class Chat
* @package app\admin\controller
*/
class Chat extends Common
{
/**
* 客服工作台
*/
public function index()
{
$user_info = Session::get('user_info');
$admin_id = $user_info['id'];
// 从数据库获取最新的login_token避免session中没有的情况
$admin = Db::name('admin')->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()]);
}
}
}

View File

@ -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 = '<div class="sessions-empty">';
@ -803,20 +818,57 @@ layui.use(['layer', 'upload'], function(){
html += ' <div class="session-info">';
html += ' <div class="name-row">';
html += ' <span class="username">' + username + '</span>';
html += ' <span class="time">' + (session.last_msg_time_fmt || '') + '</span>';
html += ' <span class="time">' + (session.last_msg_time_fmt || session.create_time_fmt || '') + '</span>';
html += ' </div>';
html += ' <div class="last-msg">' + lastMsg + '</div>';
html += ' </div>';
html += unreadBadge;
if (isPending) {
html += ' <button class="accept-btn" data-id="' + session.id + '">接入</button>';
} else {
html += unreadBadge;
}
html += '</div>';
});
}
$(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();