var curSid = 0;
var sessions = {};
var drafts = {};
var reassignedNotes = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
var polling = false;
var listSignature = "";
var typeTimer = null;
var toolsOpen = false;
var toolsOpener = null;
var staffExpired = false;
var staffLoggingOut = false;
var staffPollTimers = [];
var staffCsrf = document.querySelector('meta[name="staff-csrf-token"]').content;
var staffUsername = document.querySelector('meta[name="staff-username"]').content;
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){
if(staffExpired) throw new Error("Staff login expired");
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const headers = Object.assign({}, options && options.headers);
if(options && options.method && options.method !== "GET") headers["X-CSRF-Token"] = staffCsrf;
const response = await fetch(path, Object.assign({}, options, {headers:headers, signal:controller.signal}));
if(response.status === 401){
if(!staffExpired){
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin?expired=1");
}
throw new Error("Staff login expired");
}
if(staffExpired) throw new Error("Staff login expired");
const data = await response.json();
if(!response.ok || (data && data.ok === false)){
const error = new Error("HTTP " + response.status);
error.status = response.status;
error.userMessage = data && (data.e || data.error);
throw error;
}
return data;
}finally{
clearTimeout(timer);
}
}
async function logoutStaff(){
if(staffLoggingOut || staffExpired) return;
staffLoggingOut = true;
const button = document.getElementById("staff-logout");
const status = document.getElementById("staff-logout-status");
button.disabled = true;
button.textContent = "正在退出…";
status.hidden = true;
try{
await postAdmin("/admin/logout", {});
staffExpired = true;
staffPollTimers.forEach(clearInterval);
window.location.replace("/admin");
}catch(error){
if(!staffExpired){
status.textContent = "退出失败,请检查网络后重试。";
status.hidden = false;
}
}finally{
staffLoggingOut = false;
button.disabled = false;
button.textContent = "退出登录";
}
}
document.getElementById("staff-logout").onclick = logoutStaff;
window.addEventListener("pageshow", function(event){
if(event.persisted){
adminRequest("/api/admin/session").then(function(data){ staffCsrf = data.csrf_token; }).catch(function(){});
}
});
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("queue-overdue-count").textContent = "超时 " + queueItems.filter(function(item){ return item.overdue; }).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);
const age = Number(item.waiting_minutes) || 0;
const wait = item.overdue
? '排队超时 '+escapeText(item.overdue_minutes)+' 分钟'
: '已等待 '+escapeText(age)+' 分钟';
return '
对话 #'+sid+wait+''+escapeText(item.group_name || '综合客服')+' · '+escapeText(item.ctime)+'
';
}).join("")
: ''+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'
';
}
async function loadQueue(){
try{
const list = await adminRequest("/api/human/queue");
const waitingOverdue = new Set(list.filter(function(item){ return item.overdue; }).map(function(item){ return String(item.id); }));
queueOverdueNotified.forEach(function(id){ if(!waitingOverdue.has(id)) queueOverdueNotified.delete(id); });
const newlyOverdue = list.filter(function(item){
const id = String(item.id);
if(!item.overdue || queueOverdueNotified.has(id)) return false;
queueOverdueNotified.add(id);
return true;
});
if(newlyOverdue.length){
SupportUI.notify("人工排队超时", newlyOverdue.length+" 位客户已等待超过 5 分钟,请尽快接入。 ");
}
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(){
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
}
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 = ''+escapeText(error.userMessage || '接入失败,请稍后重试')+'
';
}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 = "我负责 · "+(sessions[sid].group_name || "综合客服");
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};
Object.assign(sessions[String(session.id)], {group_name:session.group_name, owner_username:session.owner_username});
});
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 === 'waiting' || (state.owner_username && state.owner_username !== staffUsername)){
if(!Object.prototype.hasOwnProperty.call(drafts,sid)) drafts[sid] = '';
if(sid === curSid){
drafts[sid] = replyInput.value;
reassignedNotes[sid] = document.getElementById('noteInput').value;
}else if(session.noteDraft){ reassignedNotes[sid] = session.noteDraft; }
delete sessions[sid];
if(sid === curSid) showEmptySession();
renderRecoveredDrafts();
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();
renderRecoveredDrafts();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
polling = false;
}
}
function renderRecoveredDrafts(){
const ids = Object.keys(drafts).filter(function(sid){ return !sessions[sid] && (drafts[sid] || reassignedNotes[sid]); });
document.getElementById('routing-draft-recovery').hidden = ids.length === 0;
document.getElementById('routing-retired-drafts').innerHTML = ids.map(function(sid){
return ''+
(reassignedNotes[sid] ? '' : '');
}).join('');
}
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(error.name === 'AbortError'
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
: "发送未成功,内容已保留。请确认后重试。", 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(["ticket-inbox-toggle", "ticket-overdue-toggle", "routing-open"].includes(toolsOpener) ? toolsOpener : 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]):not([hidden]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])')).filter(function(item){ return item.getClientRects().length; });
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();
var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escapeText,
username:document.querySelector('meta[name="staff-username"]').content,
notify:function(title, body){ SupportUI.notify(title, body); },
open:function(opener){
toolsOpener = opener || "ticket-inbox-toggle";
setToolsOpen(true);
document.getElementById("tickets-title").scrollIntoView({block:"start"});
document.getElementById("ticket-search").focus({preventScroll:true});
}});
loadTickets();
poll();
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];
if(window.AdminRouting){
var routingDesk = window.AdminRouting.create({username:staffUsername,request:adminRequest,
changed:function(){ loadQueue(); poll(); }});
routingDesk.refresh();
staffPollTimers.push(setInterval(routingDesk.heartbeat, 20000));
document.getElementById('routing-open').onclick = function(){
toolsOpener = 'routing-open'; setToolsOpen(true);
document.getElementById('routing-title').scrollIntoView({block:'start'});
document.getElementById('routing-refresh').focus({preventScroll:true});
routingDesk.refresh();
};
}