Files

421 lines
18 KiB
JavaScript
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.
var curSid = 0;
var sessions = {};
var drafts = {};
var queueItems = [];
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
var polling = false;
var listSignature = "";
var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var replyInput = document.getElementById("replyText");
function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
return {"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"}[character];
});
}
async function adminRequest(path, options){
const response = await fetch(path, options);
if(!response.ok) throw new Error("HTTP " + response.status);
const data = await response.json();
if(data && data.ok === false) throw new Error("Request rejected");
return data;
}
function postAdmin(path, data){
return adminRequest(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(data)});
}
function setReplyStatus(message, isError){
const status = document.getElementById("reply-status");
status.textContent = message || "";
status.hidden = !message;
status.classList.toggle("is-error", Boolean(isError));
}
function updateComposer(){
document.body.classList.toggle("has-active-session", Boolean(curSid));
const send = document.getElementById("reply-send");
send.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
send.textContent = sendingSid === curSid ? "发送中…" : "发送";
const close = document.getElementById("close-session");
close.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
close.textContent = closingSid === curSid ? "正在结束…" : "结束会话";
document.querySelectorAll(".quick").forEach(function(button){
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
});
updateAdminTools();
}
function resizeReply(){
replyInput.style.height = "auto";
replyInput.style.height = Math.min(replyInput.scrollHeight, 180) + "px";
}
function setSessionTab(tab){
const active = tab !== "queue";
document.getElementById("sessionList").hidden = !active;
document.getElementById("queue").hidden = active;
document.getElementById("tab-active").setAttribute("aria-selected", String(active));
document.getElementById("tab-queue").setAttribute("aria-selected", String(!active));
document.getElementById("tab-active").tabIndex = active ? 0 : -1;
document.getElementById("tab-queue").tabIndex = active ? -1 : 0;
}
function setChatView(open){
document.body.classList.toggle("chat-view", Boolean(open && curSid));
}
function showSessionList(){
setChatView(false);
document.getElementById("tab-active").focus();
}
function searchTerm(){
return document.getElementById("session-search").value.trim().toLowerCase();
}
function renderQueue(){
const term = searchTerm();
const visible = queueItems.filter(function(item){
return ("#" + item.id + " " + item.ctime).toLowerCase().includes(term);
});
document.getElementById("queue-count").textContent = queueItems.length;
document.getElementById("session-browser").classList.toggle("queue-empty", queueItems.length === 0);
document.getElementById("queue").innerHTML = visible.length
? visible.map(function(item){
const sid = Number(item.id);
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
async function loadQueue(){
try{
const list = await adminRequest("/api/human/queue");
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
queueItems = list;
renderQueue();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
}
}
async function loadTickets(){
const element = document.getElementById("ticketList");
try{
const list = await adminRequest("/api/tickets");
element.innerHTML = list.length ? list.map(function(ticket){
return '<div class="ticket-item">'+escapeText(ticket.content)+'<span>'+escapeText(ticket.ctime)+'</span></div>';
}).join("") : '<div class="list-empty">暂时没有客户留言</div>';
}catch(error){
element.innerHTML = '<div class="list-empty">留言暂时无法加载</div>';
}
}
async function accept(sid){
sid = String(sid);
if(sessions[sid]){ switchSession(sid); return; }
if(accepting.has(sid)) return;
accepting.add(sid);
try{
await postAdmin("/api/human/accept", {sid:Number(sid)});
if(!sessions[sid]) sessions[sid] = {lastId:0, msgs:[], unread:0};
switchSession(sid);
loadQueue();
poll();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
}finally{
accepting.delete(sid);
}
}
function switchSession(sid){
sid = String(sid);
if(!sessions[sid]) return;
if(curSid) drafts[curSid] = replyInput.value;
curSid = sid;
document.getElementById("emptyTip").style.display = "none";
document.getElementById("chatContent").style.display = "flex";
setChatView(true);
document.getElementById("chatTitle").textContent = "客户 #" + sid;
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(appendMsg);
sessions[sid].unread = 0;
replyInput.value = drafts[sid] || "";
resizeReply();
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
setReplyStatus("");
loadSessionNote(sid);
updateComposer();
renderList();
setSessionTab("active");
postAdmin("/api/human/read", {sid:Number(sid)}).catch(function(){});
}
function showEmptySession(){
curSid = 0;
clearTimeout(typeTimer);
document.getElementById("chatContent").style.display = "none";
document.getElementById("emptyTip").style.display = "flex";
document.getElementById("cInfo").textContent = "选择会话后查看客户信息";
document.getElementById("chatTitle").textContent = "会话接待";
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
replyInput.value = "";
document.getElementById("noteInput").value = "";
setReplyStatus("");
updateComposer();
setChatView(false);
}
function appendMsg(message){
const box = document.getElementById("chatBox");
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
const div = document.createElement("div");
div.setAttribute("data-message-id", String(message.id));
if(message.side === "system"){
div.className = "msg system-msg";
div.textContent = message.content;
box.appendChild(div);
if(follow) box.scrollTop = box.scrollHeight;
return;
}
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
if(message.side === "agent"){
const recall = document.createElement("button");
recall.type = "button";
recall.className = "recall-button";
recall.textContent = "撤回";
recall.onclick = function(){ recallMsg(message.id); };
div.querySelector(".meta").appendChild(recall);
}
box.appendChild(div);
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
}
function renderList(){
const term = searchTerm();
const ids = Object.keys(sessions);
document.getElementById("active-count").textContent = ids.length;
const signature = JSON.stringify([curSid, term, ids.map(function(sid){
const session = sessions[sid];
const last = session.msgs[session.msgs.length-1];
return [sid, session.unread, last && last.id, last && last.content];
})]);
if(signature === listSignature) return;
listSignature = signature;
const element = document.getElementById("sessionList");
element.innerHTML = "";
const visible = ids.filter(function(sid){
return ("对话 #"+sid).includes(term) || sessions[sid].msgs.some(function(message){
return message.content.toLowerCase().includes(term);
});
});
if(!visible.length){
element.innerHTML = '<div class="list-empty">'+(term ? "没有匹配的进行中会话" : "暂无进行中的会话")+'</div>';
}
visible.forEach(function(sid){
const session = sessions[sid];
const last = session.msgs[session.msgs.length-1];
const preview = last ? (last.side === "agent" ? "客服:" : "") + last.content.substring(0, 36) : "等待客户消息";
const div = document.createElement("button");
div.type = "button";
div.className = "conv-item" + (sid === curSid ? " active" : "");
div.setAttribute("aria-pressed", String(sid === curSid));
div.innerHTML = '<span class="avatar">客</span><span class="info"><span class="conv-title"><span class="name">客户 #'+sid+'</span><span class="conv-time">'+(last && last.ctime ? escapeText(last.ctime.slice(11,16)) : '')+'</span></span><span class="preview">'+escapeText(preview)+'</span></span>'+(session.unread ? '<span class="badge">'+session.unread+'</span>' : '');
div.onclick = function(){ switchSession(sid); };
element.appendChild(div);
});
}
async function poll(){
if(polling) return;
polling = true;
try{
const list = await adminRequest("/api/human/active");
list.forEach(function(session){
if(!sessions[String(session.id)]) sessions[String(session.id)] = {lastId:0, msgs:[], unread:0};
});
await Promise.all(Object.keys(sessions).map(async function(sid){
const session = sessions[sid];
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
if(sessions[sid] !== session) return;
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
if(sid === curSid) showEmptySession();
return;
}
state.messages.forEach(function(message){
if(session.msgs.some(function(existing){ return existing.id === message.id; })) return;
session.lastId = Math.max(session.lastId, message.id);
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
session.msgs.push(message);
if(message.side === "customer" && sid !== curSid) session.unread += 1;
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
if(sid === curSid) appendMsg(message);
});
}));
document.getElementById("sync-status").hidden = true;
renderList();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
polling = false;
}
}
async function sendReply(){
if(!curSid || sendingSid || closingSid) return;
const sid = curSid;
const draft = replyInput.value;
if(!draft.trim()) return;
drafts[sid] = draft;
sendingSid = sid;
setReplyStatus("");
updateComposer();
try{
await postAdmin("/api/human/send", {sid:Number(sid), side:"agent", content:draft.trim()});
if(drafts[sid] === draft) drafts[sid] = "";
if(curSid === sid && replyInput.value === draft){
replyInput.value = "";
resizeReply();
}
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
poll();
}catch(error){
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
}finally{
sendingSid = "";
updateComposer();
}
}
function quickReply(text){
if(!curSid || sendingSid || closingSid) return;
replyInput.value = text;
drafts[curSid] = text;
resizeReply();
setReplyStatus("常用回复已填入,可编辑后发送");
if(toolsOpen) setToolsOpen(false);
replyInput.focus();
}
async function closeSession(){
if(!curSid || closingSid || sendingSid) return;
const sid = curSid;
closingSid = sid;
updateComposer();
try{
await postAdmin("/api/human/close", {sid:Number(sid)});
delete sessions[sid];
delete drafts[sid];
if(curSid === sid) showEmptySession();
renderList();
}catch(error){
if(curSid === sid) setReplyStatus("结束失败,会话仍保留,请稍后重试。", true);
}finally{
closingSid = "";
updateComposer();
}
}
function setToolsOpen(open){
const opening = Boolean(open) && !toolsOpen;
toolsOpen = Boolean(open);
document.body.classList.toggle("tools-open", toolsOpen);
document.getElementById("tools-toggle").setAttribute("aria-expanded", String(toolsOpen));
document.getElementById("tools-from-list").setAttribute("aria-expanded", String(toolsOpen));
const panel = document.getElementById("service-tools");
panel.setAttribute("aria-hidden", String(!toolsOpen));
panel.toggleAttribute("inert", !toolsOpen);
if(opening){
document.getElementById("data-overview").open = false;
document.getElementById("tools-close").focus();
}
}
replyInput.addEventListener("keydown", function(event){
if(event.key === "Enter" && !event.shiftKey && !event.isComposing){
event.preventDefault();
sendReply();
}
});
replyInput.addEventListener("input", function(){
resizeReply();
if(!curSid) return;
drafts[curSid] = replyInput.value;
setReplyStatus("");
const sid = curSid;
postAdmin("/api/human/typing", {sid:Number(sid)}).catch(function(){});
clearTimeout(typeTimer);
typeTimer = setTimeout(function(){
postAdmin("/api/human/stop_typing", {sid:Number(sid)}).catch(function(){});
}, 3000);
});
document.getElementById("session-search").addEventListener("input", function(){ renderList(); renderQueue(); });
document.getElementById("sessions-back").onclick = showSessionList;
document.getElementById("tab-active").onclick = function(){ setSessionTab("active"); };
document.getElementById("tab-queue").onclick = function(){ setSessionTab("queue"); };
["tab-active", "tab-queue"].forEach(function(id){
document.getElementById(id).addEventListener("keydown", function(event){
if(!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" || (event.key !== "End" && id === "tab-queue") ? "tab-active" : "tab-queue";
setSessionTab(next === "tab-active" ? "active" : "queue");
document.getElementById(next).focus();
});
});
document.getElementById("tools-toggle").onclick = function(){ toolsOpener = "tools-toggle"; setToolsOpen(!toolsOpen); };
document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "tools-from-list"; setToolsOpen(!toolsOpen); };
document.getElementById("tools-close").onclick = function(){
setToolsOpen(false);
const fromList = toolsOpener === "tools-from-list" && !document.body.classList.contains("chat-view") && window.matchMedia("(max-width: 720px)").matches;
document.getElementById(fromList ? "tools-from-list" : "tools-toggle").focus();
};
document.getElementById("tools-backdrop").onclick = document.getElementById("tools-close").onclick;
document.addEventListener("keydown", function(event){
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
if(event.key === "Escape") document.getElementById("data-overview").open = false;
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
const first = items[0], last = items[items.length-1];
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
}
});
window.addEventListener("resize", function(){ setToolsOpen(toolsOpen); });
adminRequest("/api/admin/stats").then(function(data){
[data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){
document.getElementById("s"+(index+1)).textContent = value;
});
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
}).catch(function(){});
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
if(curSid) drafts[curSid] = replyInput.value;
resizeReply();
});
setToolsOpen(false);
setSessionTab("active");
updateComposer();
loadQueue();
loadTickets();
poll();
setInterval(loadQueue, 3000);
setInterval(loadTickets, 5000);
setInterval(poll, 1000);