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");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
function escapeText(value){
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
return {"&":"&", "<":"<", ">":">", '"':""", "'":"'"}[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 '
对话 #'+sid+''+escapeText(item.ctime)+'
';
}).join("")
: ''+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'
';
}
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 = '队列暂时无法加载,稍后重试
';
}
}
async function loadTickets(){
const element = document.getElementById("ticketList");
try{
const list = await adminRequest("/api/tickets");
element.innerHTML = list.length ? list.map(function(ticket){
return ''+escapeText(ticket.content)+''+escapeText(ticket.ctime)+'
';
}).join("") : '暂时没有客户留言
';
}catch(error){
element.innerHTML = '留言暂时无法加载
';
}
}
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 = '接入失败,请稍后重试
';
}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, '
');
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '↗ 查看附件');
const avatar = message.side === "customer" ? "客" : '';
div.innerHTML = ''+avatar+''+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'
'+content+'
';
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 = ''+(term ? "没有匹配的进行中会话" : "暂无进行中的会话")+'
';
}
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 = '客客户 #'+sid+''+(last && last.ctime ? escapeText(last.ctime.slice(11,16)) : '')+''+escapeText(preview)+''+(session.unread ? ''+session.unread+'' : '');
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);