Files

427 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柏悦物流 - 客服工作台</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column;}
.topbar{background:#fff;padding:10px 20px;display:flex;align-items:center;gap:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10;}
.topbar .logo{font-size:18px;font-weight:bold;color:#18479c;}
.topbar .stats{display:flex;gap:20px;font-size:13px;color:#666;margin-left:20px;}
.topbar .stats b{color:#2978e8;font-size:16px;}
.topbar .status{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;}
.topbar .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;}
.layout{flex:1;display:grid;grid-template-columns:260px 1fr 240px;overflow:hidden;}
/* 左栏:对话列表 */
.conv-list{background:#fff;border-right:1px solid #e8e8e8;overflow-y:auto;}
.conv-list h3{padding:12px 14px;font-size:13px;color:#999;border-bottom:1px solid #f0f0f0;}
.conv-item{padding:12px 14px;cursor:pointer;border-bottom:1px solid #f5f5f5;display:flex;align-items:center;gap:10px;}
.conv-item:hover{background:#f5f8fc;}
.conv-item.active{background:#e8f0ff;border-left:3px solid #2978e8;}
.conv-item .avatar{width:36px;height:36px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;}
.conv-item .info{flex:1;min-width:0;}
.conv-item .name{font-size:14px;font-weight:500;}
.conv-item .preview{font-size:12px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.conv-item .badge{background:#dc2626;color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;}
.queue-item{padding:10px 14px;background:#fff7ed;border-bottom:1px solid #ffedd5;}
.queue-item .qt{font-size:13px;color:#ea580c;font-weight:500;}
.queue-item button{margin-top:6px;width:100%;background:#ea580c;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;}
/* 中间:聊天 */
.chat-area{display:flex;flex-direction:column;background:#f8fafc;}
.chat-header{background:#fff;padding:12px 20px;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:space-between;}
.chat-header .ct{font-size:15px;font-weight:500;}
.chat-header .close{background:#dc2626;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;}
#chatBox{flex:1;overflow-y:auto;padding:20px;}
.msg{margin-bottom:12px;display:flex;}
.msg .bubble{max-width:60%;padding:10px 14px;border-radius:12px;font-size:14px;line-height:1.5;overflow-wrap:break-word;}
.msg.cust{justify-content:flex-start;}
.msg.cust .bubble{background:#fff;border:1px solid #e8e8e8;border-top-left-radius:4px;}
.msg.agent{justify-content:flex-end;}
.msg.agent .bubble{background:#2978e8;color:#fff;border-top-right-radius:4px;}
.msg .meta{font-size:11px;color:#aaa;margin-top:3px;}
.input-bar{background:#fff;border-top:1px solid #e8e8e8;padding:12px 20px;display:flex;gap:10px;}
.input-bar input{flex:1;border:1px solid #ddd;border-radius:6px;padding:10px 12px;font-size:14px;outline:none;}
.input-bar input:focus{border-color:#2978e8;}
.input-bar button{background:#2978e8;color:#fff;border:none;padding:0 20px;border-radius:6px;cursor:pointer;font-size:14px;}
/* 右栏:客户信息+快捷回复 */
.side{background:#fff;border-left:1px solid #e8e8e8;overflow-y:auto;}
.side .card{padding:16px;border-bottom:1px solid #f0f0f0;}
.side h4{font-size:13px;color:#999;margin-bottom:10px;}
.side .info-row{font-size:13px;padding:4px 0;color:#333;}
.side .quick{padding:8px 12px;background:#f5f8fc;border-radius:6px;margin-bottom:6px;font-size:13px;cursor:pointer;}
.side .quick:hover{background:#e8f0ff;}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#bbb;font-size:14px;}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">🎧 柏悦客服工作台</div>
<div class="stats">
<span>对话 <b id="s1">-</b></span>
<span>消息 <b id="s2">-</b></span>
<span>今日 <b id="s3">-</b></span>
<span>👍 <b id="s4">-</b></span>
<span>👎 <b id="s5">-</b></span>
<span>⭐ <b id="s6">-</b></span>
</div>
<div class="status">
<span id="nightBadge" style="display:none;font-size:12px;background:#1e3a5f;color:#fbbf24;padding:3px 10px;border-radius:10px;margin-right:12px;">🌙 夜间时段</span>
<a href="/api/export_chat" style="font-size:13px;color:#2978e8;text-decoration:none;margin-right:16px;">导出Excel</a>
<span class="dot"></span> 在线
</div>
</div>
<div class="layout">
<div class="conv-list">
<div style="padding:10px;border-bottom:1px solid #f0f0f0;">
<input id="searchBox" placeholder="搜索聊天记录..." style="width:100%;padding:6px 8px;border:1px solid #ddd;border-radius:4px;font-size:12px;box-sizing:border-box;" onkeydown="if(event.key==='Enter')doSearch()">
</div>
<div id="searchResult" style="position:fixed;top:70px;right:20px;background:#fff;border:2px solid #dc2626;border-radius:8px;max-height:500px;overflow-y:auto;z-index:9999;display:none;width:500px;box-shadow:0 4px 16px rgba(0,0,0,0.2);resize:both;overflow:auto;min-width:300px;min-height:200px;">
<button onclick="this.parentElement.style.display='none';document.getElementById('searchBox').value='';" style="position:absolute;top:8px;right:12px;border:none;background:#fff;color:#dc2626;border-radius:50%;width:30px;height:30px;font-size:16px;cursor:pointer;z-index:10;font-weight:bold;">✕</button>
<div id="searchHeader" style="padding:12px;background:#dc2626;color:#fff;font-weight:bold;cursor:move;">搜索结果</div>
<div id="searchBody"></div>
</div>
<h3>待接入</h3>
<div id="queue"></div>
<h3>进行中</h3>
<div id="sessionList"></div>
</div>
<div class="chat-area" id="chatArea">
<div class="empty" id="emptyTip">← 选择左侧对话开始服务</div>
<div id="chatContent" style="display:none;flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div class="chat-header">
<span class="ct" id="chatTitle">对话</span>
<div style="display:flex;gap:8px;">
<button onclick="transferSession()" style="background:#f59e0b;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;">转接</button>
<button class="close" onclick="closeSession()">结束会话</button>
</div>
</div>
<div id="chatBox"></div>
<div class="input-bar">
<button onclick="document.getElementById('emojiPanel2').style.display=document.getElementById('emojiPanel2').style.display==='block'?'none':'block'" style="font-size:20px;background:none;border:none;cursor:pointer;">😊</button>
<div id="emojiPanel2" style="display:none;position:absolute;bottom:60px;left:10px;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:99;">
<span onclick="insertEmoji2('😊')" style="cursor:pointer;font-size:20px;margin:2px;">😊</span>
<span onclick="insertEmoji2('😂')" style="cursor:pointer;font-size:20px;margin:2px;">😂</span>
<span onclick="insertEmoji2('👍')" style="cursor:pointer;font-size:20px;margin:2px;">👍</span>
<span onclick="insertEmoji2('❤️')" style="cursor:pointer;font-size:20px;margin:2px;">❤️</span>
<span onclick="insertEmoji2('🙏')" style="cursor:pointer;font-size:20px;margin:2px;">🙏</span>
<span onclick="insertEmoji2('😅')" style="cursor:pointer;font-size:20px;margin:2px;">😅</span>
<span onclick="insertEmoji2('😢')" style="cursor:pointer;font-size:20px;margin:2px;">😢</span>
<span onclick="insertEmoji2('🎉')" style="cursor:pointer;font-size:20px;margin:2px;">🎉</span>
</div>
<input type="text" id="replyText" placeholder="输入回复,Enter发送">
<button onclick="sendReply()">发送</button>
</div>
</div>
</div>
<div class="side">
<div class="card">
<h4>客户信息</h4>
<div class="info-row" id="cInfo">选择对话后显示</div>
</div>
<div class="card">
<h4>会话备注</h4>
<textarea id="noteInput" placeholder="给这个对话加备注..." style="width:100%;height:60px;border:1px solid #ddd;border-radius:4px;padding:6px;font-size:12px;resize:none;box-sizing:border-box;"></textarea>
<button onclick="saveNote()" style="width:100%;margin-top:6px;background:#2978e8;color:#fff;border:none;padding:5px;border-radius:4px;cursor:pointer;font-size:12px;">保存备注</button>
</div>
<div class="card">
<h4>快捷回复</h4>
<div class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,请问有什么可以帮您?</div>
<div class="quick" onclick="quickReply('请提供运单号,我帮您查询。')">请提供运单号</div>
<div class="quick" onclick="quickReply('已为您催促,请稍候。')">已催促,请稍候</div>
<div class="quick" onclick="quickReply('感谢您的咨询,祝您生活愉快!')">感谢咨询</div>
</div>
<div class="card">
<h4>离线留言</h4>
<div id="ticketList"></div>
</div>
</div>
</div>
<script>
var curSid = 0;
var sessions = {};
// 浏览器通知权限
if("Notification" in window && Notification.permission === "default"){
Notification.requestPermission();
}
function notify(title, body){
if(!("Notification" in window) || Notification.permission !== "granted") return;
if(!document.hidden) return; // 页面可见时不弹
var n = new Notification(title, {body: body, icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>"});
n.onclick = function(){ window.focus(); n.close(); };
}
// 夜间时段提示(22:00-8:00)
(function checkNight(){
var h = new Date().getHours();
var badge = document.getElementById("nightBadge");
if(h >= 22 || h < 8){ badge.style.display = "inline-block"; }
setInterval(checkNight, 60000);
})();
fetch("/api/admin/stats").then(function(r){return r.json()}).then(function(d){
s1.textContent=d.total_conv; s2.textContent=d.total_msg;
s3.textContent=d.today_conv; s4.textContent=d.good; s5.textContent=d.bad;
s6.textContent=d.rating_avg+"("+d.rating_count+")";
});
// 拖拽搜索弹窗
(function(){
var header = document.getElementById("searchHeader");
var box = document.getElementById("searchResult");
var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
header.onmousedown = dragMouseDown;
function dragMouseDown(e){
e = e || window.event;
e.preventDefault();
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e){
e = e || window.event;
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
box.style.right = "auto";
box.style.left = (box.offsetLeft - pos1) + "px";
box.style.top = (box.offsetTop - pos2) + "px";
}
function closeDragElement(){
document.onmouseup = null;
document.onmousemove = null;
}
})();
function doSearch(){
var q = document.getElementById("searchBox").value.trim();
if(!q) return;
fetch("/api/search_chat?q="+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(list){
document.getElementById("searchResult").style.display = "block";
var body = document.getElementById("searchBody");
if(!list.length){ body.innerHTML = '<div style="padding:10px;color:#999;">无结果</div>'; return; }
body.innerHTML = list.map(function(r){
return '<div style="padding:8px 12px;border-bottom:1px solid #f0f0f0;font-size:12px;"><b>'+r.title+'</b> ['+r.role+']<br>'+r.content+'<br><span style="color:#999;">'+r.ctime+'</span></div>';
}).join("");
});
}
function loadQueue(){
fetch("/api/human/queue").then(function(r){return r.json()}).then(function(list){
var prevCount = document.getElementById("queue").dataset.count || 0;
var curCount = list.length;
document.getElementById("queue").dataset.count = curCount;
if(curCount > prevCount && document.hidden){
notify("新客户排队", "有 " + curCount + " 位客户等待接入");
}
document.getElementById("queue").innerHTML = list.length
? list.map(function(x){return '<div class="queue-item"><div class="qt">#'+x.id+' '+x.ctime+'</div><button onclick="accept('+x.id+')">接入</button></div>'}).join("")
: '<div style="padding:10px 14px;font-size:12px;color:#ccc;">暂无排队</div>';
});
}
setInterval(loadQueue, 3000);
function loadTickets(){
fetch("/api/tickets").then(function(r){return r.json()}).then(function(list){
var el = document.getElementById("ticketList");
if(!list.length){ el.innerHTML = '<div style="font-size:12px;color:#ccc;">暂无留言</div>'; return; }
el.innerHTML = list.map(function(t){
return '<div style="padding:6px;background:#fff7ed;border-radius:6px;margin-bottom:6px;font-size:12px;">'+t.content+'<br><span style="color:#999;">'+t.ctime+'</span></div>';
}).join("");
});
}
setInterval(loadTickets, 5000);
loadTickets();
function accept(sid){
sid = String(sid);
if(sessions[sid]) return;
fetch("/api/human/accept",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})}).then(function(){
sessions[sid] = {lastId:0, msgs:[], unread:0};
switchSession(sid);
});
}
function switchSession(sid){
curSid = String(sid);
document.getElementById("emptyTip").style.display="none";
document.getElementById("chatContent").style.display="flex";
document.getElementById("chatTitle").textContent="对话 #"+sid;
document.getElementById("cInfo").innerHTML="会话编号:#"+sid;
document.getElementById("chatBox").innerHTML="";
sessions[sid].msgs.forEach(function(m){ appendMsg(m); });
sessions[sid].unread = 0;
renderList();
// 从后端拉备注
document.getElementById("noteInput").value = "加载中...";
fetch("/api/human/poll?sid="+parseInt(sid)+"&after=0").then(function(r){return r.json()}).then(function(d){
document.getElementById("noteInput").value = d.note || "";
});
fetch("/api/human/read",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(sid)})});
}
function saveNote(){
if(!curSid){ alert("请先选择一个对话"); return; }
var note = document.getElementById("noteInput").value;
sessions[curSid].note = note;
fetch("/api/human/note",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid), note:note})}).then(function(){
var btn = event.target;
var old = btn.textContent;
btn.textContent = "已保存 ✓";
btn.style.background = "#22c55e";
setTimeout(function(){ btn.textContent = old; btn.style.background = "#2978e8"; }, 1500);
});
}
function appendMsg(m){
var div=document.createElement("div");
if(m.side==="system"){
div.className="msg system-msg";
div.innerHTML='<div style="text-align:center;font-size:12px;color:#999;padding:4px 0;">'+m.content+'</div>';
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
return;
}
div.className="msg "+(m.side==="customer"?"cust":"agent");
var c = m.content.replace(/</g,"&lt;");
var imgMatch = c.match(/\[图片\](\/uploads\/\S+)/);
if(imgMatch){
c = c.replace(imgMatch[0], '<img src="'+imgMatch[1]+'" style="max-width:200px;border-radius:6px;display:block;">');
}
var recallBtn = m.side==="agent" ? '<span onclick="recallMsg('+m.id+',this)" style="font-size:11px;color:#999;cursor:pointer;margin-left:6px;">撤回</span>' : '';
div.innerHTML='<div><div class="bubble">'+c+'</div><div class="meta">'+(m.side==="customer"?"客户":"客服")+recallBtn+'</div></div>';
document.getElementById("chatBox").appendChild(div);
document.getElementById("chatBox").scrollTop=999999;
}
function recallMsg(mid, el){
fetch("/api/human/recall",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mid:mid})}).then(function(){
var msgDiv = el.closest(".msg");
if(msgDiv){
var bubble = msgDiv.querySelector(".bubble");
if(bubble) bubble.innerHTML = "";
var meta = msgDiv.querySelector(".meta");
if(meta) meta.innerHTML = "";
}
});
}
function renderList(){
var el=document.getElementById("sessionList");
el.innerHTML="";
Object.keys(sessions).forEach(function(sid){
var last = sessions[sid].msgs[sessions[sid].msgs.length-1];
var preview = last ? last.content.substring(0,20) : "等待中...";
var unread = sessions[sid].unread || 0;
var div=document.createElement("div");
div.className="conv-item"+(sid==curSid?" active":"");
div.innerHTML='<div class="avatar">'+(sid==curSid?"🟢":"👤")+'</div><div class="info"><div class="name">对话 #'+sid+'</div><div class="preview">'+preview+'</div></div>'+(unread>0?'<span class="badge">'+unread+'</span>':"");
div.onclick=function(){ switchSession(sid); };
el.appendChild(div);
});
}
function poll(){
fetch("/api/human/active").then(function(r){return r.json()}).then(function(list){
list.forEach(function(s){
if(!sessions[String(s.id)]) sessions[String(s.id)] = {lastId:0, msgs:[], unread:0};
});
renderList();
}).then(function(){
Object.keys(sessions).forEach(function(sid){
fetch("/api/human/poll?sid="+parseInt(sid)+"&after="+sessions[sid].lastId)
.then(function(r){return r.json()})
.then(function(d){
if(d.status==="closed"){
delete sessions[sid];
if(sid==curSid){ curSid=0; document.getElementById("chatContent").style.display="none"; document.getElementById("emptyTip").style.display="flex"; }
renderList(); return;
}
// 客户正在输入
var t = document.getElementById("typing-ind");
if(d.typing && sid==curSid){ if(!t){ t=document.createElement("div"); t.id="typing-ind"; t.style.cssText="font-size:12px;color:#999;padding:4px 0;"; t.textContent="客户正在输入..."; document.getElementById("chatBox").appendChild(t); } }
else if(t){ t.remove(); }
d.messages.forEach(function(m){
var exists = sessions[sid].msgs.some(function(x){return x.id===m.id});
if(exists) return;
sessions[sid].lastId=m.id;
sessions[sid].msgs.push(m);
if(m.side==="customer" && sid!=curSid){ sessions[sid].unread=(sessions[sid].unread||0)+1; renderList(); }
if(m.side==="customer" && document.hidden){
notify("客户消息 #"+sid, m.content.substring(0, 50));
}
if(sid==curSid) appendMsg(m);
});
}).catch(function(){});
});
});
}
setInterval(poll, 1000);
function insertEmoji2(e){
document.getElementById("replyText").value += e;
document.getElementById("replyText").focus();
document.getElementById("emojiPanel2").style.display = "none";
}
function sendReply(){
var t=document.getElementById("replyText").value.trim();
if(!t || !curSid) return;
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid),side:"agent",content:t})});
document.getElementById("replyText").value="";
document.getElementById("replyText").focus();
}
function quickReply(t){
document.getElementById("replyText").value=t;
sendReply();
}
function closeSession(){
fetch("/api/human/close",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
delete sessions[curSid];
curSid=0;
document.getElementById("chatContent").style.display="none";
document.getElementById("emptyTip").style.display="flex";
renderList();
}
function transferSession(){
if(!curSid) return;
if(!confirm("确定转接这个对话?转接后其他客服可在排队列表接入。")) return;
fetch("/api/human/transfer",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})}).then(function(){
delete sessions[curSid];
curSid=0;
document.getElementById("chatContent").style.display="none";
document.getElementById("emptyTip").style.display="flex";
renderList();
});
}
document.getElementById("replyText").addEventListener("keydown", function(e){
if(e.key==="Enter"){ e.preventDefault(); sendReply(); }
});
// 客服正在输入通知客户
var typeTimer = null;
document.getElementById("replyText").addEventListener("input", function(){
if(!curSid) return;
fetch("/api/human/typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
clearTimeout(typeTimer);
typeTimer = setTimeout(function(){
fetch("/api/human/stop_typing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:parseInt(curSid)})});
}, 3000);
});
</script>
</body>
</html>