调整客服自动接待和手动排队规则

This commit is contained in:
songxinnuo
2026-11-04 13:37:26 +08:00
parent efb2e5b4b4
commit ba8a7104b3
22 changed files with 1086 additions and 21 deletions
+27
View File
@@ -200,6 +200,33 @@ body:not(.has-active-session) .close { display: none; }
.ticket-history-meta { color: var(--secondary); }
.ticket-history-detail { display: block; margin-top: 3px; white-space: pre-wrap; }
.ticket-empty { padding: 16px 0; color: var(--secondary); font-size: 13px; }
.routing-heading, .routing-presence { display: flex; align-items: center; gap: 8px; }
.routing-heading { justify-content: space-between; }
.routing-heading > span { color: var(--secondary); font-size: 13px; overflow-wrap: anywhere; }
.routing-presence { margin-top: 12px; flex-wrap: wrap; }
#routing-presence-text { flex: 1; font-size: 13px; }
.routing-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.routing-dot[data-online="true"] { background: var(--success); }
.routing-presence .ticket-action { min-height: 44px; padding: 7px 10px; font-size: 13px; white-space: nowrap; }
.routing-feedback { margin-top: 6px; color: var(--secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.routing-feedback:empty { display: none; }
.routing-feedback.is-error { color: var(--danger); }
.routing-members { margin: 12px 0 0; padding: 0; list-style: none; font-size: 14px; }
.routing-members li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.routing-members li span:last-child { color: var(--secondary); font-size: 13px; }
.routing-members small { color: var(--muted); }
.routing-settings, .routing-events { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
.routing-settings summary, .routing-events summary { cursor: pointer; color: var(--secondary); font-size: 14px; min-height: 44px; padding: 10px 0; }
.routing-form { display: grid; gap: 7px; margin-top: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.routing-form label { color: var(--secondary); font-size: 14px; }
.routing-form input, .routing-form select, #routing-retired-drafts textarea { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 14px; }
.routing-form button { margin: 0; }
.routing-form :is(input,select):focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.routing-events ul { margin: 8px 0 0; padding: 0; list-style: none; }
.routing-events li { display: grid; gap: 3px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; overflow-wrap: anywhere; }
.routing-events li span, .routing-events li small { color: var(--muted); }
#routing-retired-drafts label { display: block; margin: 8px 0; font-size: 13px; }
@media (max-width: 720px) { .routing-form input, .routing-form select, #routing-retired-drafts textarea { font-size: 16px; } }
@media (max-width: 720px) { #ticket-note, #ticket-search, .ticket-toolbar select { font-size: 16px; } }
.tools-backdrop, .sessions-back { display: none; }
@media (max-width: 1200px) {
+40 -4
View File
@@ -1,6 +1,7 @@
var curSid = 0;
var sessions = {};
var drafts = {};
var reassignedNotes = {};
var queueItems = [];
var queueOverdueNotified = new Set();
var accepting = new Set();
@@ -15,6 +16,7 @@ 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'), '图片暂时无法访问,请客户重新发送附件。');
@@ -153,7 +155,7 @@ function renderQueue(){
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>';
return '<div class="queue-item'+(item.overdue ? ' is-overdue' : '')+'"><div class="qt">对话 #'+sid+wait+'<small>'+escapeText(item.group_name || '综合客服')+' · '+escapeText(item.ctime)+'</small></div><button type="button" onclick="accept('+sid+')">接入会话 →</button></div>';
}).join("")
: '<div class="list-empty">'+(term ? "没有匹配的排队会话" : "当前没有排队会话")+'</div>';
}
@@ -198,7 +200,7 @@ async function accept(sid){
loadQueue();
poll();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">接入失败,请稍后重试</div>';
document.getElementById("queue").innerHTML = '<div class="list-empty">'+escapeText(error.userMessage || '接入失败,请稍后重试')+'</div>';
}finally{
accepting.delete(sid);
}
@@ -213,7 +215,7 @@ function switchSession(sid){
document.getElementById("chatContent").style.display = "flex";
setChatView(true);
document.getElementById("chatTitle").textContent = "客户 #" + sid;
document.getElementById("chat-subtitle").textContent = "正在接待 · 人工会话";
document.getElementById("chat-subtitle").textContent = "我负责 · "+(sessions[sid].group_name || "综合客服");
document.getElementById("cInfo").textContent = "会话编号:#" + sid;
document.getElementById("chatBox").innerHTML = "";
sessions[sid].msgs.forEach(appendMsg);
@@ -316,11 +318,23 @@ async function poll(){
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];
@@ -339,6 +353,7 @@ async function poll(){
}));
document.getElementById("sync-status").hidden = true;
renderList();
renderRecoveredDrafts();
}catch(error){
document.getElementById("sync-status").hidden = false;
}finally{
@@ -346,6 +361,15 @@ async function poll(){
}
}
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 '<label>会话 #'+Number(sid)+' · 未发送草稿<textarea readonly rows="3">'+escapeText(drafts[sid] || '')+'</textarea></label>'+
(reassignedNotes[sid] ? '<label>备注草稿<textarea readonly rows="3">'+escapeText(reassignedNotes[sid])+'</textarea></label>' : '');
}).join('');
}
async function sendReply(){
if(!curSid || sendingSid || closingSid) return;
const sid = curSid;
@@ -454,7 +478,7 @@ 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(["ticket-inbox-toggle", "ticket-overdue-toggle"].includes(toolsOpener) ? toolsOpener : fromList ? "tools-from-list" : "tools-toggle").focus();
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){
@@ -494,3 +518,15 @@ var ticketDesk = window.AdminTickets.create({request:adminRequest, escape:escape
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();
};
}
+170
View File
@@ -0,0 +1,170 @@
/* Group configuration, explicit availability and versioned manual reassignment. */
(function(root){
function create(options){
const el = id => document.getElementById(id);
const escape = value => String(value == null ? '' : value).replace(/[&<>"']/g,
character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
let snapshot = null, busy = false, beating = false, generation = 0, selectedVersion = null;
const controls = ['routing-presence-toggle','routing-refresh','routing-create-group','routing-save-member','routing-save-assignment'];
function message(text, error){
['routing-status','routing-management-status'].forEach(id=>{
el(id).textContent = text || '';
el(id).classList.toggle('is-error', Boolean(error));
});
}
function setBusy(value){
busy = value;
controls.forEach(id => { el(id).disabled = value || !snapshot; });
}
function fill(id, items, placeholder){
const input = el(id), previous = input.value;
input.innerHTML = (placeholder ? '<option value="">'+escape(placeholder)+'</option>' : '') +
items.map(item => '<option value="'+escape(item.value)+'">'+escape(item.label)+'</option>').join('');
input.value = items.some(item => String(item.value) === previous) ? previous :
(placeholder ? '' : items.length ? String(items[0].value) : '');
}
function renderPresence(online){
el('routing-presence-text').textContent = online ? '在线,参与自动分配' : '离线,不自动接待';
el('routing-presence-dot').dataset.online = String(online);
el('routing-presence-toggle').textContent = online ? '切换离线' : '上线接待';
el('routing-presence-toggle').setAttribute('aria-pressed', String(online));
}
function targets(preserveUnavailable){
if(!snapshot) return;
const group = Number(el('routing-target-group').value);
const previous = el('routing-target-owner').value;
const items = snapshot.agents.filter(agent => agent.online && agent.group_id === group)
.map(agent => ({value:agent.username,label:(agent.name || agent.username)+' · '+agent.active_count+' 个会话'}));
if(preserveUnavailable && previous && !items.some(item=>item.value === previous)){
items.push({value:previous,label:previous+' · 状态已改变,请重新选择'});
}
fill('routing-target-owner',items,'转入队列,等待手动接入');
}
function selectSession(){
const session = snapshot && snapshot.sessions.find(item => String(item.id) === el('routing-session').value);
selectedVersion = session ? session.version : null;
if(session){
el('routing-target-group').value = String(session.group_id);
targets();
el('routing-target-owner').value = session.owner_username || '';
}
}
function render(data){
snapshot = data;
renderPresence(Boolean(data.presence.online));
const mine = data.agents.find(agent => agent.username === options.username);
el('routing-group-label').textContent = mine ? mine.group_name : '综合客服';
el('routing-members').innerHTML = data.agents.length ? data.agents.map(agent =>
'<li><span>'+escape(agent.name || agent.username)+' <small>'+escape(agent.username)+'</small></span>'+
'<span>'+escape(agent.group_name)+' · '+(agent.online ? '在线' : '离线')+' · '+agent.active_count+' 个会话</span></li>').join('') : '<li>暂无客服账号</li>';
const groups = data.groups.map(group => ({value:group.id,label:group.name}));
fill('routing-member-group', groups);
fill('routing-target-group', groups);
fill('routing-member', data.agents.map(agent => ({value:agent.username,label:(agent.name || agent.username)+'('+agent.username+')'})), '选择客服');
fill('routing-session', data.sessions.map(session => ({value:session.id,
label:'#'+session.id+' · '+session.group_name+' · '+(session.owner_name || session.owner_username || '排队中')})), '选择会话');
if(!el('routing-session').value) selectedVersion = null;
targets(true);
const actions = {auto_assign:'自动分配',claim:'手动接入',reassign:'手动改派',queue:'转入分组队列',transfer:'转回队列',close:'结束会话'};
el('routing-events').innerHTML = data.events.length ? data.events.map(event =>
'<li><strong>#'+event.session_id+' · '+escape(actions[event.action] || event.action)+'</strong><span>'+
escape(event.group_name)+' → '+escape(event.owner_username || '等待分配')+'</span><small>'+escape(event.actor === 'system' ? '系统' : event.actor === 'customer' ? '客户' : event.actor)+' · '+escape(event.ctime)+'</small></li>').join('') : '<li>暂无分配记录</li>';
setBusy(busy);
}
async function refresh(afterWrite){
if(busy && afterWrite !== true) return;
const current = ++generation;
try{
const data = await options.request('/api/admin/routing');
if(current !== generation) return;
if(!data || !Array.isArray(data.groups) || !Array.isArray(data.agents) ||
!Array.isArray(data.sessions) || !Array.isArray(data.events) || !data.presence) throw new Error('Invalid routing data');
render(data);
}catch(error){
if(current === generation) message('分配状态加载失败,已保留现有信息;请点击刷新重试。', true);
}
}
async function heartbeat(){
if(busy || beating) return;
beating = true;
const current = generation;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:'{}'});
if(current !== generation) return;
if(snapshot) snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
await refresh();
}catch(error){
if(current === generation){
el('routing-presence-text').textContent = '接待状态待确认';
message('接待状态同步失败,断线超过 90 秒将停止自动分配;请刷新核对。', true);
}
}finally{ beating = false; }
}
async function setPresence(){
if(busy || !snapshot) return;
++generation;
setBusy(true);
const online = !snapshot.presence.online;
try{
const data = await options.request('/api/admin/routing/presence', {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({online})});
snapshot.presence.online = Boolean(data.online);
renderPresence(Boolean(data.online));
message(data.online ? '已上线,空闲时自动接入一个新客户;多接请手动领取。' : '已离线,已接待的会话继续保留。');
if(options.changed) options.changed();
}catch(error){ message('状态更新未确认成功,请点击刷新核对后重试。', true); }
finally{ setBusy(false); }
}
async function write(path, data, success){
if(busy || !snapshot) return false;
++generation;
setBusy(true);
try{
await options.request('/api/admin/routing/'+path, {method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
message(success);
if(options.changed) options.changed();
return true;
}catch(error){
message(error.userMessage || '操作未确认成功,请刷新核对后重试。', true);
return false;
}finally{ await refresh(true); setBusy(false); }
}
async function addGroup(){
const name = el('routing-group-name').value.trim();
if(!name || name.length > 40){ message('请输入 1–40 字的分组名称。', true); return; }
if(await write('groups',{name},'分组已创建,可将客服加入此组。')){
if(el('routing-group-name').value.trim() === name) el('routing-group-name').value = '';
}
}
async function moveMember(){
const username = el('routing-member').value, group_id = Number(el('routing-member-group').value);
if(!username){ message('请先选择客服。', true); return; }
await write('members',{username,group_id},'客服分组已更新,现有会话负责人不变。');
}
async function assign(){
const sid = Number(el('routing-session').value), group_id = Number(el('routing-target-group').value);
if(!sid || selectedVersion === null){ message('请先选择会话。', true); return; }
const version = selectedVersion, username = el('routing-target-owner').value || null;
await write('assign',{sid,group_id,version,username},'会话分配已更新。');
el('routing-session').value = ''; selectedVersion = null;
}
el('routing-presence-toggle').addEventListener('click',setPresence);
el('routing-refresh').addEventListener('click',refresh);
el('routing-session').addEventListener('change',selectSession);
el('routing-target-group').addEventListener('change',()=>targets(false));
el('routing-member').addEventListener('change',()=>{
const member = snapshot && snapshot.agents.find(agent => agent.username === el('routing-member').value);
if(member) el('routing-member-group').value = String(member.group_id);
});
[['routing-group-form',addGroup],['routing-member-form',moveMember],['routing-assign-form',assign]].forEach(([id,handler])=>{
el(id).addEventListener('submit',event=>{event.preventDefault();return handler();});
});
setBusy(false);
return {refresh,heartbeat,setPresence,assign};
}
root.AdminRouting = {create};
})(window);