171 lines
10 KiB
JavaScript
171 lines
10 KiB
JavaScript
/* 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|