1354 lines
45 KiB
HTML
1354 lines
45 KiB
HTML
|
|
<title>在线客服</title>
|
|
|
|
<div class="layui-card layadmin-header">
|
|
<div class="layui-breadcrumb" lay-filter="breadcrumb">
|
|
<a lay-href="">主页</a>
|
|
<a><cite>在线客服</cite></a>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="padding: 10px 20px; background: #fff; border-bottom: 1px solid #e6e6e6;">
|
|
<span style="font-weight: bold;">选择客服:</span>
|
|
<select id="customer-select" style="padding: 5px 10px; border: 1px solid #ddd; border-radius: 4px; margin-right: 10px;" onchange="onCustomerChange()">
|
|
<option value="">请选择客服账号</option>
|
|
</select>
|
|
<span style="font-weight: bold; margin-left: 10px;">状态:</span>
|
|
<button id="status-btn" class="layui-btn layui-btn-xs" onclick="toggleCustomerStatus()">设为在线</button>
|
|
<span id="status-display" class="layui-badge" style="margin-left: 10px;">离线</span>
|
|
<span style="margin-left: 20px; color: #666; font-size: 12px;">(请先在"客服管理"中添加客服账号)</span>
|
|
</div>
|
|
|
|
<div class="layui-fluid" style="padding: 0; height: calc(100vh - 130px); overflow: hidden;">
|
|
<div class="layui-row layui-col-space0" style="height: 100%;">
|
|
<!-- 左侧会话列表 -->
|
|
<div class="layui-col-md3" style="height: 100%;">
|
|
<div class="conversation-list">
|
|
<div class="conversation-header">
|
|
<span><i class="layui-icon layui-icon-dialogue"></i> 会话列表</span>
|
|
<span class="layui-badge layui-bg-orange" id="online-count">0</span>
|
|
</div>
|
|
<div class="conversation-list-container" id="conversation-list-container"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右侧聊天区域 -->
|
|
<div class="layui-col-md9" style="height: 100%; display: flex; flex-direction: column;">
|
|
<div class="chat-area" style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
|
|
<div class="chat-header" style="flex-shrink: 0;">
|
|
<span class="chat-header-name" id="current-user">请选择会话</span>
|
|
<span id="current-status" class="layui-badge" style="margin-left: 10px;"></span>
|
|
</div>
|
|
|
|
<!-- 消息展示区域 -->
|
|
<div id="message-container" class="message-container" style="flex: 1; min-height: 100px;">
|
|
<div class="empty-state">
|
|
<i class="layui-icon layui-icon-dialogue"></i>
|
|
<p>请选择左侧会话开始聊天</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 发送消息区域 -->
|
|
<div class="input-area" style="flex-shrink: 0;">
|
|
<div class="quick-reply">
|
|
<span onclick="insertText('您好,请问有什么可以帮助您?')">您好</span>
|
|
<span onclick="insertText('请稍等,我为您查询一下')">稍等</span>
|
|
<span onclick="insertText('感谢您的咨询,祝您生活愉快!')">结束</span>
|
|
<span onclick="document.getElementById('image-upload').click()">
|
|
<i class="layui-icon layui-icon-upload"></i> 图片
|
|
</span>
|
|
<input type="file" id="image-upload" accept="image/*" style="display: none;" onchange="handleImageUpload(this)">
|
|
</div>
|
|
<div class="input-wrapper">
|
|
<textarea id="message-input" class="message-textarea" placeholder="输入消息内容..." disabled></textarea>
|
|
<button id="send-btn" class="send-btn" disabled onclick="sendMessage()">发送</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* 整体布局 */
|
|
html, body, .layui-layout-admin, .layui-body {
|
|
overflow: hidden !important;
|
|
height: 100%;
|
|
}
|
|
|
|
.layui-fluid {
|
|
overflow: hidden;
|
|
height: calc(100vh - 60px);
|
|
padding: 0;
|
|
}
|
|
|
|
/* 隐藏所有滚动条 */
|
|
* {
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
}
|
|
|
|
*::-webkit-scrollbar {
|
|
display: none;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
/* 会话列表样式 */
|
|
.conversation-list {
|
|
height: 100%;
|
|
background: #fff;
|
|
border-right: 1px solid #e6e6e6;
|
|
}
|
|
|
|
.conversation-header {
|
|
padding: 12px 15px;
|
|
background: #009688;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.conversation-list-container {
|
|
height: calc(100% - 45px);
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* 隐藏滚动条 */
|
|
.conversation-list-container::-webkit-scrollbar { width: 0; }
|
|
|
|
.conversation-item {
|
|
padding: 12px 15px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.conversation-item:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.conversation-item.active {
|
|
background: #e6f7f5;
|
|
border-left: 3px solid #009688;
|
|
}
|
|
|
|
.conversation-avatar {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #009688 0%, #26a69a 100%);
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
margin-right: 12px;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
|
|
.conversation-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.conversation-name {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #333;
|
|
margin-bottom: 4px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.conversation-preview {
|
|
font-size: 12px;
|
|
color: #999;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.conversation-meta {
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
.conversation-time {
|
|
font-size: 11px;
|
|
color: #bbb;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
/* 未读标记 */
|
|
.unread-dot {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: #ff5722;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.unread-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
background: #ff5722;
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* 聊天区域 */
|
|
.chat-area {
|
|
background: linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%);
|
|
height: 100%;
|
|
}
|
|
|
|
.chat-header {
|
|
padding: 16px 24px;
|
|
background: #fff;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
align-items: center;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.chat-header-name {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
/* 消息区域 */
|
|
.message-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.message-container::-webkit-scrollbar { width: 0; }
|
|
|
|
.message-wrapper {
|
|
display: flex;
|
|
margin-bottom: 16px;
|
|
animation: fadeIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.message-wrapper.user {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.message-wrapper.customer {
|
|
flex-direction: row-reverse;
|
|
}
|
|
|
|
.message-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.message-wrapper.user .message-avatar {
|
|
background: linear-gradient(135deg, #009688 0%, #26a69a 100%);
|
|
color: #fff;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.message-wrapper.customer .message-avatar {
|
|
background: linear-gradient(135deg, #ff9800 0%, #ffc107 100%);
|
|
color: #fff;
|
|
margin-left: 12px;
|
|
}
|
|
|
|
.message-content {
|
|
max-width: 65%;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.message-bubble {
|
|
padding: 12px 18px;
|
|
border-radius: 16px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
.message-wrapper.user .message-bubble {
|
|
background: #fff;
|
|
color: #333;
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
|
|
.message-wrapper.customer .message-bubble {
|
|
background: linear-gradient(135deg, #009688 0%, #00796b 100%);
|
|
color: #fff;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
|
|
.message-time {
|
|
font-size: 11px;
|
|
color: #bbb;
|
|
margin-top: 6px;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.message-wrapper.user .message-time {
|
|
text-align: left;
|
|
}
|
|
|
|
.message-wrapper.customer .message-time {
|
|
text-align: right;
|
|
}
|
|
|
|
.message-image {
|
|
max-width: 250px;
|
|
max-height: 200px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.message-image:hover {
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
/* 输入区域 */
|
|
.input-area {
|
|
background: #fff;
|
|
padding: 16px 20px;
|
|
border-top: 1px solid #eee;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.03);
|
|
}
|
|
|
|
.input-wrapper {
|
|
display: flex !important;
|
|
gap: 12px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.message-textarea {
|
|
flex: 1;
|
|
border: 2px solid #e8e8e8;
|
|
border-radius: 12px;
|
|
padding: 12px 16px;
|
|
font-size: 14px;
|
|
resize: none;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
min-height: 50px;
|
|
max-height: 100px;
|
|
display: block !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
}
|
|
|
|
.message-textarea:focus {
|
|
border-color: #009688;
|
|
}
|
|
|
|
.send-btn {
|
|
width: 80px !important;
|
|
height: 50px !important;
|
|
background: linear-gradient(135deg, #009688 0%, #00796b 100%) !important;
|
|
color: #fff !important;
|
|
border: none !important;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
box-shadow: 0 2px 8px rgba(0,150,136,0.3);
|
|
display: inline-block !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
}
|
|
|
|
.send-btn:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0,150,136,0.4);
|
|
}
|
|
|
|
.send-btn:disabled {
|
|
background: #ccc;
|
|
cursor: not-allowed;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.quick-reply {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.quick-reply span {
|
|
padding: 6px 14px;
|
|
background: #f0f5f4;
|
|
border-radius: 16px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.quick-reply span:hover {
|
|
background: #e0f2f1;
|
|
color: #009688;
|
|
}
|
|
|
|
/* 空状态 */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #999;
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 60px;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state p {
|
|
font-size: 15px;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
layui.use(['admin', 'table', 'form'], function () {
|
|
var $ = layui.$,
|
|
admin = layui.admin,
|
|
table = layui.table,
|
|
setter = layui.setter,
|
|
form = layui.form,
|
|
customerApiUrl = setter.customerApiUrl || setter.baseurl;
|
|
|
|
// 当前登录的客服信息
|
|
var currentCustomerId = '';
|
|
var currentCustomerName = '';
|
|
var currentConversationId = '';
|
|
var conversationList = [];
|
|
var messageCache = {};
|
|
var customerList = [];
|
|
var readConversations = new Set(); // 已读会话集合
|
|
var unreadCounts = {}; // 每个会话的未读消息计数 { conversationId: number }
|
|
|
|
// 初始化客服信息
|
|
function initCustomerInfo() {
|
|
// 从后台获取客服列表
|
|
loadCustomerList();
|
|
}
|
|
|
|
// 加载客服列表
|
|
function loadCustomerList() {
|
|
admin.req({
|
|
method: "get",
|
|
url: setter.baseurl + "adminapi/customer/list?pageIndex=1&pageSize=100",
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
done: function (res) {
|
|
console.log('客服列表响应:', res);
|
|
// 处理响应格式
|
|
var list = [];
|
|
if (res && res.code === 0) {
|
|
// 直接是数组
|
|
if (Array.isArray(res.data)) {
|
|
list = res.data;
|
|
}
|
|
// 嵌套结构 { data: { data: [...] } }
|
|
else if (res.data && res.data.data && Array.isArray(res.data.data)) {
|
|
list = res.data.data;
|
|
}
|
|
}
|
|
customerList = list;
|
|
renderCustomerSelect();
|
|
},
|
|
fail: function(err) {
|
|
console.error('加载客服列表失败:', err);
|
|
customerList = [];
|
|
renderCustomerSelect();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 渲染客服选择下拉框
|
|
function renderCustomerSelect() {
|
|
var select = document.getElementById('customer-select');
|
|
if (!select) return;
|
|
|
|
select.innerHTML = '<option value="">请选择客服账号</option>';
|
|
|
|
if (!customerList || !Array.isArray(customerList)) {
|
|
return;
|
|
}
|
|
|
|
customerList.forEach(function(customer) {
|
|
if (!customer) return;
|
|
var userId = customer.userId || '';
|
|
var userName = customer.userName || '未知';
|
|
|
|
var option = document.createElement('option');
|
|
option.value = userId;
|
|
option.textContent = userName + ' (' + userId + ')';
|
|
option.setAttribute('data-name', userName);
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// 客服选择变更
|
|
window.onCustomerChange = function() {
|
|
var select = document.getElementById('customer-select');
|
|
var option = select.options[select.selectedIndex];
|
|
|
|
currentCustomerId = select.value;
|
|
currentCustomerName = option.getAttribute('data-name') || '客服';
|
|
|
|
// 重置状态
|
|
isCustomerOnline = false;
|
|
updateStatusDisplay();
|
|
|
|
// 如果选择了客服,检查在线状态并加入客服队列
|
|
if (currentCustomerId) {
|
|
checkCustomerStatus();
|
|
loadConversations();
|
|
|
|
// 加入客服队列以接收新会话通知
|
|
if (signalRConnection && signalRCore && signalRConnection.state === signalRCore.HubConnectionState.Connected) {
|
|
signalRConnection.invoke("JoinCustomerQueue", currentCustomerId)
|
|
.then(function() {
|
|
console.log("已加入客服队列:", currentCustomerId);
|
|
})
|
|
.catch(function(err) {
|
|
console.error("加入客服队列失败:", err);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 加载会话列表
|
|
// 加载会话列表
|
|
function loadConversations() {
|
|
if (!currentCustomerId) {
|
|
conversationList = [];
|
|
renderConversationList();
|
|
var countEl = document.getElementById('online-count');
|
|
if (countEl) countEl.textContent = '0';
|
|
return;
|
|
}
|
|
|
|
admin.req({
|
|
method: "get",
|
|
url: customerApiUrl + "customerapi/customer/conversations?customerId=" + currentCustomerId,
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
done: function (res) {
|
|
if (res.code === 0 && res.data) {
|
|
var newData = res.data;
|
|
// 数据未变化时跳过重渲染,避免闪烁
|
|
var oldJson = JSON.stringify(conversationList);
|
|
var newJson = JSON.stringify(newData);
|
|
if (oldJson === newJson) return;
|
|
|
|
conversationList = newData;
|
|
// 用服务端返回的 unreadCount 初始化/更新未读数
|
|
conversationList.forEach(function(conv) {
|
|
if (!readConversations.has(conv.id)) {
|
|
var serverUnread = conv.unreadCount || conv.UnreadCount || 0;
|
|
// 服务端有数据就用服务端的(刷新时重新同步)
|
|
if (serverUnread > 0) {
|
|
unreadCounts[conv.id] = serverUnread;
|
|
} else if (unreadCounts[conv.id] === undefined) {
|
|
unreadCounts[conv.id] = 0;
|
|
}
|
|
}
|
|
});
|
|
renderConversationList();
|
|
updateOnlineCount();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 渲染会话列表
|
|
function renderConversationList() {
|
|
var container = document.getElementById('conversation-list-container');
|
|
if (!container) {
|
|
console.error('conversation-list-container not found');
|
|
return;
|
|
}
|
|
|
|
if (!conversationList || conversationList.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><i class="layui-icon layui-icon-dialogue"></i><p>暂无会话</p></div>';
|
|
return;
|
|
}
|
|
|
|
// 按用户ID分组,同一个用户的会话合并显示最新的一条
|
|
var userMap = {};
|
|
conversationList.forEach(function(conv) {
|
|
// 处理字段名大小写问题
|
|
var uid = conv.userId || conv.userid || conv.id;
|
|
var uname = conv.userName || conv.username || '未知用户';
|
|
var ctime = conv.createTime || conv.createtime || new Date();
|
|
var sta = conv.status || 0;
|
|
|
|
if (!userMap[uid]) {
|
|
userMap[uid] = {
|
|
userId: uid,
|
|
userName: uname,
|
|
conversationId: conv.id,
|
|
createTime: ctime,
|
|
status: sta,
|
|
unreadCount: 0,
|
|
lastMessage: null
|
|
};
|
|
}
|
|
// 更新最新会话(按时间取最新的)
|
|
if (new Date(ctime) > new Date(userMap[uid].createTime)) {
|
|
userMap[uid].conversationId = conv.id;
|
|
userMap[uid].createTime = ctime;
|
|
userMap[uid].status = sta;
|
|
}
|
|
// 累加各会话的未读计数(从 unreadCounts 读取,仅统计未读会话)
|
|
if (!readConversations.has(conv.id)) {
|
|
userMap[uid].unreadCount += (unreadCounts[conv.id] || 0);
|
|
}
|
|
// 更新最后一条消息
|
|
if (messageCache[conv.id] && messageCache[conv.id].length > 0) {
|
|
var lastMsg = messageCache[conv.id][messageCache[conv.id].length - 1];
|
|
if (!userMap[uid].lastMessage || new Date(lastMsg.createTime || lastMsg.createtime) > new Date(userMap[uid].lastMessage.createTime || userMap[uid].lastMessage.createtime)) {
|
|
userMap[uid].lastMessage = lastMsg;
|
|
}
|
|
}
|
|
});
|
|
|
|
var groupedList = Object.values(userMap);
|
|
// 按时间排序,最新的在前面
|
|
groupedList.sort(function(a, b) {
|
|
return new Date(b.createTime) - new Date(a.createTime);
|
|
});
|
|
|
|
var html = '';
|
|
groupedList.forEach(function(conv) {
|
|
var hasUnread = conv.unreadCount > 0;
|
|
var lastMsgText = conv.lastMessage ? (conv.lastMessage.contentType === 2 || conv.lastMessage.contenttype === 2 ? '[图片]' : conv.lastMessage.content || '') : '暂无消息';
|
|
var isActive = conv.conversationId === currentConversationId;
|
|
|
|
html += '<div class="conversation-item' + (isActive ? ' active' : '') + '" conversation-id="' + conv.conversationId + '" onclick="selectConversation(\'' + conv.conversationId + '\', \'' + conv.userId + '\')">' +
|
|
'<div class="conversation-avatar">' + (conv.userName ? conv.userName.substr(0, 1) : '?') + (hasUnread ? '<span class="unread-dot"></span>' : '') + '</div>' +
|
|
'<div class="conversation-info">' +
|
|
'<div class="conversation-name">' + (conv.userName || '未知用户') + '</div>' +
|
|
'<div class="conversation-preview">' + lastMsgText + '</div>' +
|
|
'</div>' +
|
|
'<div class="conversation-meta">' +
|
|
'<div class="conversation-time">' + formatTime(conv.createTime) + '</div>' +
|
|
(hasUnread ? '<span class="unread-badge">' + conv.unreadCount + '</span>' : '') +
|
|
'</div>' +
|
|
'</div>';
|
|
});
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// 选择会话
|
|
window.selectConversation = function(conversationId, userId) {
|
|
// 移除其他选中状态
|
|
document.querySelectorAll('.conversation-item').forEach(function(el) {
|
|
el.classList.remove('active');
|
|
});
|
|
|
|
// 选中当前会话
|
|
var item = document.querySelector('[conversation-id="' + conversationId + '"]');
|
|
if (item) item.classList.add('active');
|
|
|
|
// 更新当前会话ID
|
|
currentConversationId = conversationId;
|
|
|
|
// 标记该用户所有会话为已读,清零未读计数
|
|
conversationList.forEach(function(c) {
|
|
if ((c.userId || c.userid) === userId) {
|
|
readConversations.add(c.id);
|
|
unreadCounts[c.id] = 0;
|
|
}
|
|
});
|
|
|
|
// 刷新会话列表显示
|
|
renderConversationList();
|
|
|
|
// 更新顶部未读总数
|
|
updateOnlineCount();
|
|
|
|
// 获取会话信息
|
|
var conv = conversationList.find(function(c) { return c.id === conversationId; });
|
|
if (conv) {
|
|
document.getElementById('current-user').textContent = conv.userName || conv.username || '未知用户';
|
|
var statusText = conv.status === 0 ? '等待中' : (conv.status === 1 ? '进行中' : '已结束');
|
|
var statusBadge = document.getElementById('current-status');
|
|
statusBadge.textContent = statusText;
|
|
statusBadge.className = 'layui-badge ' + (conv.status === 1 ? 'layui-bg-green' : (conv.status === 0 ? 'layui-bg-orange' : 'layui-bg-gray'));
|
|
}
|
|
|
|
// 启用输入框
|
|
document.getElementById('message-input').disabled = false;
|
|
document.getElementById('send-btn').disabled = false;
|
|
|
|
// 通过SignalR加入会话组
|
|
if (signalRConnection && signalRCore && signalRConnection.state === signalRCore.HubConnectionState.Connected) {
|
|
// 加入当前会话
|
|
signalRConnection.invoke('JoinConversation', conversationId)
|
|
.then(function() {
|
|
console.log('已加入会话组:', conversationId);
|
|
})
|
|
.catch(function(err) {
|
|
console.error('加入会话组失败:', err);
|
|
});
|
|
|
|
// 加入该用户的所有会话
|
|
var userConversations = conversationList.filter(function(c) { return c.userId === userId; });
|
|
userConversations.forEach(function(c) {
|
|
if (c.id !== conversationId) {
|
|
signalRConnection.invoke('JoinConversation', c.id)
|
|
.catch(function(err) {
|
|
console.error('加入会话组失败:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
// 加载消息记录
|
|
loadMessages(conversationId, userId);
|
|
};
|
|
|
|
// 加载消息记录
|
|
function loadMessages(conversationId, userId) {
|
|
// 加载消息时标记为已读,清零该用户所有会话的未读计数
|
|
conversationList.forEach(function(c) {
|
|
if ((c.userId || c.userid) === userId) {
|
|
readConversations.add(c.id);
|
|
unreadCounts[c.id] = 0;
|
|
}
|
|
});
|
|
|
|
// 通知后端标记已读(该用户所有会话)
|
|
$.ajax({
|
|
url: customerApiUrl + "customerapi/customer/mark-read",
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({ customerId: currentCustomerId, userId: userId }),
|
|
success: function(res) {
|
|
console.log('已标记用户所有会话为已读:', userId);
|
|
// 后端标记成功后,重新加载会话列表让未读数归零
|
|
loadConversations();
|
|
},
|
|
error: function(err) {
|
|
console.error('标记已读失败:', err);
|
|
}
|
|
});
|
|
|
|
// 更新未读总数
|
|
updateOnlineCount();
|
|
renderConversationList();
|
|
|
|
// 加载当前会话消息
|
|
loadConversationMessages(conversationId, function() {
|
|
// 如果有userId,加载该用户所有会话的消息
|
|
if (userId) {
|
|
var userConversations = conversationList.filter(function(c) {
|
|
return (c.userId || c.userid) === userId;
|
|
});
|
|
var loadedCount = 0;
|
|
var totalToLoad = userConversations.length - 1;
|
|
if (totalToLoad <= 0) {
|
|
showAllUserMessages(userId);
|
|
} else {
|
|
userConversations.forEach(function(c) {
|
|
if (c.id !== conversationId) {
|
|
loadConversationMessages(c.id, function() {
|
|
loadedCount++;
|
|
if (loadedCount >= totalToLoad) {
|
|
showAllUserMessages(userId);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// 兜底:确保当前会话消息一定渲染,不受userId分组异常影响
|
|
if (messageCache[conversationId] && messageCache[conversationId].length > 0) {
|
|
renderMessages(messageCache[conversationId]);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 加载单个会话的消息
|
|
function loadConversationMessages(convId, callback) {
|
|
admin.req({
|
|
method: "get",
|
|
url: customerApiUrl + "customerapi/customer/messages/" + convId,
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
done: function (res) {
|
|
if (res.code === 0 && res.data) {
|
|
messageCache[convId] = res.data;
|
|
renderConversationList();
|
|
if (callback) callback();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 显示用户所有消息(合并排序)
|
|
function showAllUserMessages(userId) {
|
|
var userConversations = conversationList.filter(function(c) {
|
|
return (c.userId || c.userid) === userId;
|
|
});
|
|
var allMessages = [];
|
|
userConversations.forEach(function(c) {
|
|
if (messageCache[c.id]) {
|
|
allMessages = allMessages.concat(messageCache[c.id]);
|
|
}
|
|
});
|
|
// 按时间排序
|
|
allMessages.sort(function(a, b) {
|
|
return new Date(a.createTime || a.createtime) - new Date(b.createTime || b.createtime);
|
|
});
|
|
renderMessages(allMessages);
|
|
}
|
|
|
|
// 渲染消息
|
|
function renderMessages(messages) {
|
|
var container = document.getElementById('message-container');
|
|
if (!container) return;
|
|
|
|
if (!messages || messages.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><i class="layui-icon layui-icon-dialogue"></i><p>暂无消息记录</p></div>';
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
messages.forEach(function(msg) {
|
|
// 处理字段名大小写
|
|
var senderType = msg.senderType || msg.sendertype || 2;
|
|
var senderName = msg.senderName || msg.sendername || '未知';
|
|
var contentType = msg.contentType || msg.contenttype || 1;
|
|
var content = msg.content || msg.content || '';
|
|
var createTime = msg.createTime || msg.createtime || new Date();
|
|
|
|
var isCustomer = senderType === 2;
|
|
var isUser = senderType === 1;
|
|
|
|
var time = '';
|
|
if (createTime) {
|
|
var date = new Date(createTime);
|
|
var utc = date.getTime() + (date.getTimezoneOffset() * 60000);
|
|
var beijingDate = new Date(utc + (3600000 * 8));
|
|
time = beijingDate.toLocaleString('zh-CN', { hour12: false });
|
|
}
|
|
|
|
var msgContent = contentType === 2
|
|
? '<img src="' + content + '" class="message-image" onclick="viewImage(\'' + content + '\')">'
|
|
: content;
|
|
|
|
var avatarText = senderName ? senderName.substr(0, 1) : '?';
|
|
|
|
html += '<div class="message-wrapper ' + (isCustomer ? 'customer' : 'user') + '">' +
|
|
'<div class="message-avatar">' + avatarText + '</div>' +
|
|
'<div class="message-content">' +
|
|
'<div class="message-bubble">' + msgContent + '</div>' +
|
|
'<div class="message-time">' + senderName + ' · ' + time + '</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
});
|
|
container.innerHTML = html;
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// 查看大图
|
|
window.viewImage = function(url) {
|
|
layer.open({
|
|
type: 1,
|
|
title: '图片预览',
|
|
area: ['800px', '600px'],
|
|
content: '<div style="text-align:center;padding:20px;"><img src="' + url + '" style="max-width:100%;max-height:100%;"></div>'
|
|
});
|
|
};
|
|
|
|
// 发送消息 - 优先SignalR,备用HTTP
|
|
function sendMessage() {
|
|
if (!currentConversationId) {
|
|
layer.msg('请选择会话');
|
|
return;
|
|
}
|
|
|
|
var content = document.getElementById('message-input').value.trim();
|
|
if (!content) {
|
|
layer.msg('请输入消息内容');
|
|
return;
|
|
}
|
|
|
|
// 优先使用SignalR发送
|
|
if (signalRConnection && signalRCore && signalRConnection.state === signalRCore.HubConnectionState.Connected) {
|
|
console.log('通过SignalR发送消息到会话:', currentConversationId);
|
|
signalRConnection.invoke('SendMessage', currentConversationId, 2, currentCustomerId, currentCustomerName, null, 1, content,"")
|
|
.then(function() {
|
|
document.getElementById('message-input').value = '';
|
|
// 不手动添加消息,等待SignalR回调 OnMessageReceived 来更新UI
|
|
})
|
|
.catch(function(err) {
|
|
console.error('SignalR发送失败,尝试HTTP:', err);
|
|
sendMessageByHttp(content);
|
|
});
|
|
} else {
|
|
sendMessageByHttp(content);
|
|
}
|
|
}
|
|
|
|
// 通过HTTP API发送消息
|
|
function sendMessageByHttp(content) {
|
|
admin.req({
|
|
method: "post",
|
|
contentType: "application/json;charset=UTF-8",
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
url: customerApiUrl + "customerapi/customer/message",
|
|
data: JSON.stringify({
|
|
conversationId: currentConversationId,
|
|
senderType: 2,
|
|
senderId: currentCustomerId,
|
|
senderName: currentCustomerName,
|
|
contentType: 1,
|
|
content: content
|
|
}),
|
|
done: function (res) {
|
|
if (res.code === 0) {
|
|
document.getElementById('message-input').value = '';
|
|
var newMsg = {
|
|
conversationId: currentConversationId,
|
|
senderType: 2,
|
|
senderId: currentCustomerId,
|
|
senderName: currentCustomerName,
|
|
contentType: 1,
|
|
content: content,
|
|
createTime: new Date()
|
|
};
|
|
if (!messageCache[currentConversationId]) {
|
|
messageCache[currentConversationId] = [];
|
|
}
|
|
messageCache[currentConversationId].push(newMsg);
|
|
renderMessages(messageCache[currentConversationId]);
|
|
} else {
|
|
layer.msg(res.info || '发送失败');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 快捷输入
|
|
window.insertText = function(text) {
|
|
var input = document.getElementById('message-input');
|
|
input.value += text;
|
|
};
|
|
|
|
// 图片上传处理
|
|
window.handleImageUpload = function(input) {
|
|
if (!currentConversationId) {
|
|
layer.msg('请先选择会话');
|
|
input.value = '';
|
|
return;
|
|
}
|
|
|
|
var file = input.files[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
layer.msg('请选择图片文件');
|
|
input.value = '';
|
|
return;
|
|
}
|
|
|
|
var formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
layer.msg('正在上传图片...');
|
|
|
|
admin.req({
|
|
method: 'post',
|
|
url: customerApiUrl + 'customerapi/ImageUpload/upload',
|
|
data: formData,
|
|
processData: false,
|
|
contentType: false,
|
|
done: function(res) {
|
|
if (res.code === 0) {
|
|
var imageUrl = res.path || res.data;
|
|
console.log('图片上传成功:', imageUrl);
|
|
|
|
// 通过SignalR发送图片
|
|
if (signalRConnection && signalRCore && signalRConnection.state === signalRCore.HubConnectionState.Connected) {
|
|
signalRConnection.invoke('SendMessage', currentConversationId, 2, currentCustomerId, currentCustomerName, null, 2, imageUrl,"")
|
|
.then(function() {
|
|
console.log('图片消息发送成功');
|
|
})
|
|
.catch(function(err) {
|
|
console.error('发送失败,尝试HTTP:', err);
|
|
sendImageByHttp(imageUrl);
|
|
});
|
|
} else {
|
|
sendImageByHttp(imageUrl);
|
|
}
|
|
} else {
|
|
layer.msg(res.info || '图片上传失败');
|
|
}
|
|
input.value = '';
|
|
},
|
|
fail: function(res) {
|
|
layer.msg('图片上传失败');
|
|
input.value = '';
|
|
}
|
|
});
|
|
};
|
|
|
|
// 通过HTTP发送图片
|
|
function sendImageByHttp(imageUrl) {
|
|
admin.req({
|
|
method: "post",
|
|
contentType: "application/json;charset=UTF-8",
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
url: customerApiUrl + "customerapi/customer/message",
|
|
data: JSON.stringify({
|
|
conversationId: currentConversationId,
|
|
senderType: 2,
|
|
senderId: currentCustomerId,
|
|
senderName: currentCustomerName,
|
|
contentType: 2,
|
|
content: imageUrl
|
|
}),
|
|
done: function(res) {
|
|
if (res.code === 0) {
|
|
var newMsg = {
|
|
conversationId: currentConversationId,
|
|
senderType: 2,
|
|
senderId: currentCustomerId,
|
|
senderName: currentCustomerName,
|
|
contentType: 2,
|
|
content: imageUrl,
|
|
createTime: new Date()
|
|
};
|
|
if (!messageCache[currentConversationId]) {
|
|
messageCache[currentConversationId] = [];
|
|
}
|
|
messageCache[currentConversationId].push(newMsg);
|
|
renderMessages(messageCache[currentConversationId]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 客服在线状态
|
|
var isCustomerOnline = false;
|
|
|
|
// 切换客服在线状态
|
|
window.toggleCustomerStatus = function() {
|
|
var newStatus = isCustomerOnline ? 0 : 1;
|
|
|
|
admin.req({
|
|
method: "post",
|
|
contentType: "application/json;charset=UTF-8",
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
url: customerApiUrl + "customerapi/customer/status",
|
|
data: JSON.stringify({
|
|
userId: currentCustomerId,
|
|
status: newStatus
|
|
}),
|
|
done: function (res) {
|
|
if (res.code === 0) {
|
|
isCustomerOnline = !isCustomerOnline;
|
|
updateStatusDisplay();
|
|
layer.msg(isCustomerOnline ? '已上线' : '已离线');
|
|
|
|
// 如果上线了,重新加载会话列表
|
|
if (isCustomerOnline) {
|
|
loadConversations();
|
|
}
|
|
} else {
|
|
layer.msg(res.info || '操作失败');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// 更新状态显示
|
|
function updateStatusDisplay() {
|
|
var btn = document.getElementById('status-btn');
|
|
var display = document.getElementById('status-display');
|
|
|
|
if (!btn || !display) return;
|
|
|
|
if (isCustomerOnline) {
|
|
btn.textContent = '设为离线';
|
|
btn.className = 'layui-btn layui-btn-xs layui-btn-danger';
|
|
display.textContent = '在线';
|
|
display.className = 'layui-badge layui-bg-green';
|
|
} else {
|
|
btn.textContent = '设为在线';
|
|
btn.className = 'layui-btn layui-btn-xs';
|
|
display.textContent = '离线';
|
|
display.className = 'layui-badge';
|
|
}
|
|
}
|
|
|
|
// 更新顶部未读消息总数
|
|
function updateOnlineCount() {
|
|
var totalUnread = 0;
|
|
conversationList.forEach(function(conv) {
|
|
if (!readConversations.has(conv.id)) {
|
|
totalUnread += (unreadCounts[conv.id] || 0);
|
|
}
|
|
});
|
|
var countEl = document.getElementById('online-count');
|
|
if (countEl) {
|
|
countEl.textContent = totalUnread;
|
|
countEl.style.display = totalUnread > 0 ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
// 检查当前客服在线状态
|
|
function checkCustomerStatus() {
|
|
if (!currentCustomerId) {
|
|
isCustomerOnline = false;
|
|
updateStatusDisplay();
|
|
return;
|
|
}
|
|
|
|
admin.req({
|
|
method: "get",
|
|
url: customerApiUrl + "customerapi/customer/online",
|
|
headers: {
|
|
'token': layui.data(setter.tableName)[setter.request.tokenName]
|
|
},
|
|
done: function (res) {
|
|
if (res.code === 0 && res.data) {
|
|
var onlineIds = res.data.map(function(c) { return c.userId; });
|
|
isCustomerOnline = onlineIds.indexOf(currentCustomerId) !== -1;
|
|
} else {
|
|
isCustomerOnline = false;
|
|
}
|
|
updateStatusDisplay();
|
|
}
|
|
});
|
|
}
|
|
|
|
// SignalR连接实例
|
|
var signalRConnection = null;
|
|
var signalRCore = null; // SignalR核心对象
|
|
|
|
// 初始化SignalR连接
|
|
function initSignalR() {
|
|
signalRCore = window.signalR || layui.signalR;
|
|
if (!signalRCore) {
|
|
console.warn('SignalR not loaded, trying to load...');
|
|
var script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/@microsoft/signalr@6.0.0/dist/browser/signalr.min.js';
|
|
script.onload = function() {
|
|
console.log('SignalR loaded, starting connection...');
|
|
signalRCore = window.signalR;
|
|
startSignalRConnection();
|
|
};
|
|
script.onerror = function() {
|
|
console.error('SignalR load failed');
|
|
};
|
|
document.head.appendChild(script);
|
|
return;
|
|
}
|
|
|
|
startSignalRConnection();
|
|
}
|
|
|
|
function startSignalRConnection() {
|
|
if (!signalRCore) {
|
|
console.error('signalRCore is null');
|
|
return;
|
|
}
|
|
|
|
console.log('初始化SignalR连接,Hub地址:', customerApiUrl + "customerHub");
|
|
|
|
signalRConnection = new signalRCore.HubConnectionBuilder()
|
|
.withUrl(customerApiUrl + "customerHub")
|
|
.withHubProtocol(new signalRCore.JsonHubProtocol())
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
// 监听消息接收
|
|
signalRConnection.on("OnMessageReceived", function(data) {
|
|
console.log("收到消息:", data);
|
|
|
|
var msgConvId = data.conversationId || data.ConversationId;
|
|
|
|
if (!messageCache[msgConvId]) {
|
|
messageCache[msgConvId] = [];
|
|
}
|
|
messageCache[msgConvId].push(data);
|
|
|
|
// 如果不是当前会话,增加未读计数
|
|
if (msgConvId !== currentConversationId) {
|
|
unreadCounts[msgConvId] = (unreadCounts[msgConvId] || 0) + 1;
|
|
}
|
|
|
|
// 更新顶部未读总数
|
|
updateOnlineCount();
|
|
|
|
// 如果是当前会话,刷新消息列表
|
|
if (currentConversationId === msgConvId) {
|
|
console.log("是当前会话,刷新消息列表");
|
|
renderMessages(messageCache[msgConvId]);
|
|
}
|
|
|
|
playNotificationSound();
|
|
});
|
|
|
|
// 监听新会话通知
|
|
signalRConnection.on("OnNewConversation", function(data) {
|
|
console.log("收到新会话通知:", data);
|
|
|
|
// 如果是分配给自己的会话,刷新会话列表
|
|
if (data.customerId === currentCustomerId) {
|
|
layer.msg('有新用户发起会话: ' + (data.userName || '未知用户'), {icon: 1, time: 3000});
|
|
loadConversations();
|
|
}
|
|
});
|
|
|
|
// 启动连接
|
|
signalRConnection.start().then(function() {
|
|
console.log("SignalR connected");
|
|
|
|
// 加入自己客服的队列
|
|
if (currentCustomerId) {
|
|
signalRConnection.invoke("JoinCustomerQueue", currentCustomerId)
|
|
.then(function() {
|
|
console.log("已加入客服队列:", currentCustomerId);
|
|
})
|
|
.catch(function(err) {
|
|
console.error("加入客服队列失败:", err);
|
|
});
|
|
}
|
|
|
|
conversationList.forEach(function(conv) {
|
|
if (conv.status === 1) {
|
|
signalRConnection.invoke("JoinConversation", conv.id);
|
|
}
|
|
});
|
|
}).catch(function(err) {
|
|
console.error("SignalR connection failed:", err);
|
|
});
|
|
}
|
|
|
|
// 通过SignalR发送消息
|
|
function sendMessageViaSignalR(conversationId, senderType, senderId, senderName, content) {
|
|
if (!signalRConnection || signalRConnection.state !== signalR.HubConnectionState.Connected) {
|
|
return false;
|
|
}
|
|
|
|
signalRConnection.invoke('SendMessage', conversationId, senderType, senderId, senderName, null, 1, content,"")
|
|
.then(function() {
|
|
console.log('SignalR消息发送成功');
|
|
return true;
|
|
})
|
|
.catch(function(err) {
|
|
console.error('SignalR发送失败:', err);
|
|
return false;
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
// 播放提示音
|
|
function playNotificationSound() {
|
|
try {
|
|
var audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleA4AQu3W459MBABQrtXpr3YRAFHI');
|
|
audio.volume = 0.3;
|
|
audio.play().catch(function() {});
|
|
} catch(e) {}
|
|
}
|
|
|
|
// 格式化时间
|
|
function formatTime(timestamp) {
|
|
if (!timestamp) return '';
|
|
|
|
var date;
|
|
if (typeof timestamp === 'string') {
|
|
date = new Date(timestamp);
|
|
} else {
|
|
date = new Date(timestamp);
|
|
}
|
|
|
|
// 转换为北京时间 (UTC+8)
|
|
var utc = date.getTime() + (date.getTimezoneOffset() * 60000);
|
|
var beijingDate = new Date(utc + (3600000 * 8));
|
|
|
|
var now = new Date();
|
|
var nowUtc = now.getTime() + (now.getTimezoneOffset() * 60000);
|
|
var nowBeijing = new Date(nowUtc + (3600000 * 8));
|
|
|
|
var diff = nowBeijing - beijingDate;
|
|
|
|
if (diff < 60000) return '刚刚';
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
|
|
return (beijingDate.getMonth() + 1) + '-' + beijingDate.getDate();
|
|
}
|
|
|
|
// 绑定事件
|
|
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
document.getElementById('message-input').addEventListener('keydown', function(e) {
|
|
if (e.ctrlKey && e.key === 'Enter') {
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
// 初始化
|
|
initCustomerInfo();
|
|
initSignalR();
|
|
|
|
// 定时轮询刷新会话列表(30秒间隔,SignalR已负责实时推送,轮询仅做兜底)
|
|
setInterval(function() {
|
|
if (currentCustomerId) {
|
|
loadConversations();
|
|
}
|
|
}, 30000);
|
|
});
|
|
</script>
|