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 = '
'+(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);