保存今天的修改

This commit is contained in:
songxinnuo
2026-11-02 17:51:10 +08:00
parent 04f94ad665
commit eebaba8084
44 changed files with 2831 additions and 154 deletions
+96 -21
View File
@@ -2,6 +2,7 @@ var curSid = 0;
var sessions = {};
var drafts = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
var sendingSid = "";
var closingSid = "";
@@ -10,6 +11,10 @@ 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 replyInput = document.getElementById("replyText");
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
@@ -20,13 +25,66 @@ function escapeText(value){
}
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;
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)});
}
@@ -86,11 +144,16 @@ function renderQueue(){
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);
return '<div class="queue-item"><div class="qt">对话 #'+sid+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
const age = Number(item.waiting_minutes) || 0;
const wait = item.overdue
? '<span class="queue-overdue">排队超时 '+escapeText(item.overdue_minutes)+' 分钟</span>'
: '<span class="queue-wait">已等待 '+escapeText(age)+' 分钟</span>';
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
@@ -98,6 +161,17 @@ function renderQueue(){
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 + " 位客户等待接入");
}
@@ -109,15 +183,7 @@ async function loadQueue(){
}
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>';
}
return Promise.all([ticketDesk.load(), ticketDesk.refreshSummary()]);
}
async function accept(sid){
@@ -299,7 +365,9 @@ async function sendReply(){
if(curSid === sid){ replyInput.focus(); setReplyStatus("已发送"); }
poll();
}catch(error){
if(curSid === sid) setReplyStatus("发送未成功,内容已保留。请确认后重试。", true);
if(curSid === sid) setReplyStatus(error.name === 'AbortError'
? "发送超时,内容已保留。请先确认聊天记录,再决定是否重试。"
: "发送未成功,内容已保留。请确认后重试。", true);
}finally{
sendingSid = "";
updateComposer();
@@ -386,14 +454,14 @@ document.getElementById("tools-from-list").onclick = function(){ toolsOpener = "
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(["ticket-inbox-toggle", "ticket-overdue-toggle"].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]), a[href], textarea:not([disabled])'));
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(); }
@@ -414,8 +482,15 @@ 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();
setInterval(loadQueue, 3000);
setInterval(loadTickets, 5000);
setInterval(poll, 1000);
staffPollTimers = [setInterval(loadQueue, 3000), setInterval(loadTickets, 5000), setInterval(poll, 1000)];