568 lines
21 KiB
JavaScript
568 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
const byId = (id) => document.getElementById(id);
|
|
const exampleField = byId('example');
|
|
const actorField = byId('actor');
|
|
const messageField = byId('message');
|
|
const intake = byId('intake-form');
|
|
const submit = byId('submit');
|
|
const detail = byId('ticket-detail');
|
|
const ticketRows = byId('ticket-rows');
|
|
const regularFilter = byId('regular-filter');
|
|
const spamFilter = byId('spam-filter');
|
|
const panelView = byId('panel-view');
|
|
const exitView = byId('panel-exit-view');
|
|
const exitButton = byId('panel-exit');
|
|
const returnButton = byId('panel-return');
|
|
const clearButton = byId('clear-buffer');
|
|
|
|
let config;
|
|
let examples = [];
|
|
let tickets = [];
|
|
let selectedId = null;
|
|
let attempt = null;
|
|
let queueRefresh = Promise.resolve();
|
|
let folder = 'regular';
|
|
let currentDetail = null;
|
|
let generation = 0;
|
|
let exited = false;
|
|
let clearing = false;
|
|
let queueTimer;
|
|
|
|
function isCurrent(expected) {
|
|
return !exited && generation === expected;
|
|
}
|
|
|
|
function invalidatePanel() {
|
|
generation += 1;
|
|
window.clearTimeout(queueTimer);
|
|
queueRefresh = Promise.resolve();
|
|
}
|
|
|
|
function element(tag, content = '', className = '') {
|
|
const node = document.createElement(tag);
|
|
node.textContent = content;
|
|
node.className = className;
|
|
return node;
|
|
}
|
|
|
|
function option(value, title) {
|
|
const node = element('option', title);
|
|
node.value = value;
|
|
return node;
|
|
}
|
|
|
|
function fillSelect(select, values, selected) {
|
|
select.replaceChildren(...Object.entries(values).map(([value, title]) => option(value, title)));
|
|
select.value = selected;
|
|
}
|
|
|
|
function feedback(id, text = '', isError = false) {
|
|
const status = byId(id);
|
|
status.textContent = text;
|
|
status.dataset.error = isError;
|
|
}
|
|
|
|
async function api(url, method = 'GET', body) {
|
|
const options = { method };
|
|
if (body !== undefined) {
|
|
options.headers = { 'Content-Type': 'application/json' };
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
let response;
|
|
try {
|
|
response = await fetch(url, options);
|
|
} catch {
|
|
throw new Error('Сервер недоступен. Проверьте, что прототип запущен.');
|
|
}
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
throw new Error('Сервер вернул ответ в неожиданном формате. Проверьте, что прототип запущен.');
|
|
}
|
|
if (!response.ok) throw new Error(data.error || 'Не удалось выполнить действие.');
|
|
return data;
|
|
}
|
|
|
|
function modeLabel(ticket) {
|
|
if (ticket.mode === 'demo') return 'Демо · правила';
|
|
return ticket.provider === 'yandex' ? 'LLM · Yandex AI Studio' : 'LLM · OpenAI';
|
|
}
|
|
|
|
function setIntakeDisabled(disabled) {
|
|
for (const field of [exampleField, actorField, messageField, submit]) field.disabled = disabled;
|
|
intake.setAttribute('aria-busy', String(disabled));
|
|
}
|
|
|
|
exampleField.addEventListener('change', () => {
|
|
if (exampleField.value === '') return;
|
|
const example = examples[Number(exampleField.value)];
|
|
actorField.value = example.actor;
|
|
messageField.value = example.text;
|
|
feedback('intake-feedback');
|
|
});
|
|
|
|
intake.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
if (exited || clearing) return;
|
|
const expected = generation;
|
|
const actor = actorField.value;
|
|
const text = messageField.value.trim();
|
|
if (!text) {
|
|
feedback('intake-feedback', 'Введите текст обращения.', true);
|
|
messageField.focus();
|
|
return;
|
|
}
|
|
if (!attempt || attempt.actor !== actor || attempt.text !== text) {
|
|
attempt = { actor, text, requestId: crypto.randomUUID() };
|
|
}
|
|
setIntakeDisabled(true);
|
|
submit.textContent = 'Обрабатываем…';
|
|
feedback('intake-feedback');
|
|
try {
|
|
const { ticket } = await api('/api/tickets', 'POST', attempt);
|
|
if (!isCurrent(expected)) return;
|
|
attempt = null;
|
|
exampleField.value = '';
|
|
actorField.value = '';
|
|
messageField.value = '';
|
|
selectedId = ticket.id;
|
|
folder = ticketFolder(ticket);
|
|
feedback('intake-feedback', `Обращение №${ticket.id} записано.`);
|
|
await refreshQueue().catch((error) => {
|
|
if (isCurrent(expected)) feedback('queue-feedback', error.message, true);
|
|
});
|
|
} catch (error) {
|
|
if (isCurrent(expected)) feedback('intake-feedback', error.message, true);
|
|
} finally {
|
|
if (isCurrent(expected)) {
|
|
setIntakeDisabled(false);
|
|
submit.textContent = 'Обработать и записать';
|
|
}
|
|
}
|
|
});
|
|
|
|
const dateFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
|
|
|
function ticketFolder(ticket) {
|
|
return ticket.queue === 'spam' || ticket.category === 'spam' ? 'spam' : 'regular';
|
|
}
|
|
|
|
function ticketChat(ticket, draft) {
|
|
const section = element('section', '', 'ticket-chat');
|
|
const heading = element('h4', 'Переписка');
|
|
const scrollWindow = element('div', '', 'chat-window');
|
|
const messages = element('ol', '', 'chat-log');
|
|
messages.setAttribute('role', 'log');
|
|
messages.setAttribute('aria-label', 'Переписка');
|
|
const chatStatus = element('p', 'Загрузка переписки…', 'note');
|
|
scrollWindow.append(messages);
|
|
scrollWindow.hidden = true;
|
|
section.append(heading, scrollWindow, chatStatus);
|
|
|
|
const replyForm = element('form');
|
|
const replyLabel = element('label', 'Ответ в чат');
|
|
replyLabel.htmlFor = 'chat-reply';
|
|
const reply = element('textarea');
|
|
reply.id = replyLabel.htmlFor;
|
|
reply.rows = 3;
|
|
reply.maxLength = 2500;
|
|
reply.required = true;
|
|
const copyDraft = element('button', 'Вставить черновик', 'secondary');
|
|
copyDraft.type = 'button';
|
|
copyDraft.addEventListener('click', () => {
|
|
reply.value = draft.value;
|
|
reply.focus();
|
|
});
|
|
const send = element('button', 'Отправить в чат');
|
|
send.type = 'submit';
|
|
const actions = element('div', '', 'reply-actions');
|
|
actions.append(send, copyDraft);
|
|
const replyStatus = element('p', '', 'feedback');
|
|
replyStatus.setAttribute('role', 'status');
|
|
replyForm.append(replyLabel, reply, actions);
|
|
section.append(replyForm, replyStatus);
|
|
|
|
const endpoint = `/api/tickets/${ticket.id}/messages`;
|
|
const authorLabels = { customer: 'Пользователь', staff: 'Сотрудник', moderator: 'Модерация', operator: 'Поддержка' };
|
|
let renderedCount = 0;
|
|
let refreshPending;
|
|
let replyAttempt;
|
|
|
|
function updateCategory(category) {
|
|
replyForm.hidden = category === 'spam';
|
|
}
|
|
|
|
function renderMessages(items) {
|
|
if (!section.isConnected || items.length < renderedCount) return;
|
|
const wasAtBottom = scrollWindow.scrollHeight - scrollWindow.scrollTop - scrollWindow.clientHeight < 40;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const item of items.slice(renderedCount)) {
|
|
const bubble = element('li');
|
|
bubble.dataset.role = item.role;
|
|
const meta = element('div', '', 'chat-meta');
|
|
const time = element('time', dateFormat.format(new Date(item.createdAt)));
|
|
time.dateTime = item.createdAt;
|
|
meta.append(element('strong', authorLabels[item.role] || 'Участник'), time);
|
|
bubble.append(meta, element('p', item.text));
|
|
fragment.append(bubble);
|
|
}
|
|
messages.append(fragment);
|
|
renderedCount = items.length;
|
|
scrollWindow.hidden = renderedCount === 0;
|
|
chatStatus.hidden = renderedCount > 0;
|
|
chatStatus.textContent = 'Сообщений пока нет.';
|
|
if (wasAtBottom) scrollWindow.scrollTop = scrollWindow.scrollHeight;
|
|
}
|
|
|
|
function refresh() {
|
|
if (refreshPending) return refreshPending;
|
|
refreshPending = api(endpoint).then(({ messages: items }) => renderMessages(items)).catch(() => {
|
|
if (!renderedCount) chatStatus.textContent = 'Не удалось загрузить переписку.';
|
|
}).finally(() => { refreshPending = null; });
|
|
return refreshPending;
|
|
}
|
|
|
|
replyForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const text = reply.value.trim();
|
|
if (!text || send.disabled || replyForm.hidden || clearing || exited) return;
|
|
const expected = generation;
|
|
if (replyAttempt?.text !== text) replyAttempt = { requestId: crypto.randomUUID(), text };
|
|
send.disabled = true;
|
|
copyDraft.disabled = true;
|
|
reply.disabled = true;
|
|
replyStatus.textContent = '';
|
|
try {
|
|
const { messages: items } = await api(endpoint, 'POST', replyAttempt);
|
|
if (!isCurrent(expected) || !section.isConnected) return;
|
|
renderMessages(items);
|
|
scrollWindow.scrollTop = scrollWindow.scrollHeight;
|
|
replyAttempt = null;
|
|
reply.value = '';
|
|
replyStatus.textContent = 'Ответ отправлен.';
|
|
replyStatus.dataset.error = false;
|
|
await refreshQueue(true).catch(() => {});
|
|
} catch (error) {
|
|
if (isCurrent(expected) && section.isConnected) {
|
|
replyStatus.textContent = error.message;
|
|
replyStatus.dataset.error = true;
|
|
}
|
|
} finally {
|
|
send.disabled = false;
|
|
copyDraft.disabled = false;
|
|
reply.disabled = false;
|
|
}
|
|
});
|
|
|
|
updateCategory(ticket.category);
|
|
return { section, refresh, updateCategory };
|
|
}
|
|
|
|
function renderDetail(ticket) {
|
|
currentDetail = null;
|
|
detail.replaceChildren();
|
|
detail.hidden = !ticket;
|
|
if (!ticket) return;
|
|
|
|
const title = element('h3', `Обращение №${ticket.id}`);
|
|
const created = dateFormat.format(new Date(ticket.createdAt));
|
|
const meta = element('p', `${ticket.channel} · ${config.actors[ticket.actor]} · ${created} · ${modeLabel(ticket)}`, 'detail-meta');
|
|
const text = element('p', ticket.text, 'detail-text');
|
|
const reason = element('p', `Первичная оценка. ${ticket.reason}`, 'note');
|
|
detail.append(title, meta);
|
|
if (ticket.externalId) detail.append(element('p', `ID в источнике ${ticket.externalId}`, 'detail-meta source-id'));
|
|
text.hidden = ticket.hasConversation;
|
|
const review = element('p', 'Нужна проверка деталей', 'note review');
|
|
review.hidden = !ticket.needsReview || ticket.status !== 'new';
|
|
detail.append(text, reason, review);
|
|
|
|
const form = element('form');
|
|
const controls = element('div', '', 'controls');
|
|
const category = element('select');
|
|
category.id = 'edit-category';
|
|
fillSelect(category, config.categories, ticket.category);
|
|
const status = element('select');
|
|
status.id = 'edit-status';
|
|
fillSelect(status, config.statuses, ticket.status);
|
|
const categoryLabel = element('label', 'Категория');
|
|
categoryLabel.htmlFor = category.id;
|
|
const statusLabel = element('label', 'Статус');
|
|
statusLabel.htmlFor = status.id;
|
|
const categoryControl = element('div');
|
|
categoryControl.append(categoryLabel, category);
|
|
const statusControl = element('div');
|
|
statusControl.append(statusLabel, status);
|
|
controls.append(categoryControl, statusControl);
|
|
form.append(controls);
|
|
|
|
const impact = element('input');
|
|
impact.type = 'checkbox';
|
|
impact.checked = ticket.studentImpact;
|
|
if (ticket.actor === 'staff') {
|
|
const impactLabel = element('label', '', 'checkbox');
|
|
impactLabel.append(impact, element('span', 'Сбой уже затрагивает учеников'));
|
|
form.append(impactLabel);
|
|
}
|
|
|
|
const draft = element('textarea');
|
|
draft.id = 'edit-draft';
|
|
draft.rows = 4;
|
|
draft.maxLength = 2500;
|
|
draft.required = true;
|
|
draft.value = ticket.draft;
|
|
const draftLabel = element('label');
|
|
draftLabel.htmlFor = draft.id;
|
|
const updateDraftLabel = () => {
|
|
draftLabel.textContent = category.value === 'spam' ? 'Рекомендация оператору' : 'Черновик ответа';
|
|
};
|
|
category.addEventListener('change', updateDraftLabel);
|
|
updateDraftLabel();
|
|
const save = element('button', 'Сохранить правку');
|
|
save.type = 'submit';
|
|
form.append(draftLabel, draft, save);
|
|
const thread = ticket.hasConversation ? ticketChat(ticket, draft) : null;
|
|
if (thread) detail.append(thread.section);
|
|
detail.append(form);
|
|
|
|
const fieldValues = () => ({
|
|
category: category.value, status: status.value, studentImpact: impact.checked, draft: draft.value,
|
|
});
|
|
let savedValues = fieldValues();
|
|
let saving = false;
|
|
|
|
function applyFields(updated) {
|
|
category.value = updated.category;
|
|
status.value = updated.status;
|
|
impact.checked = updated.studentImpact;
|
|
draft.value = updated.draft;
|
|
savedValues = fieldValues();
|
|
updateDraftLabel();
|
|
}
|
|
|
|
function updateDetail(updated) {
|
|
if (!updated || updated.id !== ticket.id) return;
|
|
reason.textContent = `Первичная оценка. ${updated.reason}`;
|
|
review.hidden = !updated.needsReview || updated.status !== 'new';
|
|
thread?.updateCategory(updated.category);
|
|
const dirty = Object.entries(fieldValues()).some(([key, value]) => value !== savedValues[key]);
|
|
if (!saving && !dirty && !form.contains(document.activeElement)) applyFields(updated);
|
|
}
|
|
|
|
currentDetail = { update: updateDetail, refresh: thread?.refresh };
|
|
thread?.refresh();
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
if (saving || clearing || exited) return;
|
|
const expected = generation;
|
|
const patch = Object.fromEntries(Object.entries(fieldValues()).filter(([key, value]) => value !== savedValues[key]));
|
|
if (!Object.keys(patch).length) return;
|
|
const fields = [category, status, impact, draft, save];
|
|
saving = true;
|
|
for (const field of fields) field.disabled = true;
|
|
feedback('queue-feedback');
|
|
try {
|
|
const { ticket: updated } = await api(`/api/tickets/${ticket.id}`, 'PATCH', patch);
|
|
if (!isCurrent(expected) || !form.isConnected) return;
|
|
applyFields(updated);
|
|
if (selectedId === ticket.id) {
|
|
folder = ticketFolder(updated);
|
|
tickets = tickets.map((existing) => existing.id === updated.id ? updated : existing);
|
|
renderQueue(true);
|
|
}
|
|
feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены.`);
|
|
await refreshQueue(true).catch(() => {
|
|
if (isCurrent(expected)) {
|
|
feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены. Обновите страницу, чтобы загрузить очередь.`);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (isCurrent(expected) && form.isConnected) feedback('queue-feedback', error.message, true);
|
|
} finally {
|
|
saving = false;
|
|
for (const field of fields) field.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderQueue(preserveDetail = false) {
|
|
const selected = tickets.find((ticket) => ticket.id === selectedId);
|
|
if (!selected) selectedId = null;
|
|
const visibleTickets = tickets.filter((ticket) => ticketFolder(ticket) === folder);
|
|
const focusedId = ticketRows.contains(document.activeElement) ? document.activeElement.dataset.ticket : null;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const ticket of visibleTickets) {
|
|
const row = element('tr');
|
|
const summary = element('td');
|
|
const preview = ticket.text.replace(/\s+/gu, ' ');
|
|
const short = preview.length > 94 ? `${preview.slice(0, 93)}…` : preview;
|
|
const show = element('button', `№${ticket.id} · ${short}`, 'ticket-button');
|
|
show.type = 'button';
|
|
show.setAttribute('aria-expanded', String(ticket.id === selectedId));
|
|
show.setAttribute('aria-controls', detail.id);
|
|
show.addEventListener('click', () => {
|
|
selectedId = selectedId === ticket.id ? null : ticket.id;
|
|
feedback('queue-feedback');
|
|
renderQueue();
|
|
ticketRows.querySelector(`[data-ticket="${ticket.id}"]`)?.focus();
|
|
});
|
|
show.dataset.ticket = ticket.id;
|
|
summary.append(show, element('span', `${ticket.channel} · ${config.actors[ticket.actor]} · ${config.topics[ticket.topic]}`, 'ticket-meta'));
|
|
|
|
const category = element('td');
|
|
const badge = element('span', config.categories[ticket.category], 'tag');
|
|
badge.dataset.category = ticket.category;
|
|
category.append(badge);
|
|
row.append(summary, category, element('td', config.queues[ticket.queue]), element('td', config.statuses[ticket.status]));
|
|
fragment.append(row);
|
|
}
|
|
|
|
ticketRows.replaceChildren(fragment);
|
|
byId('empty').hidden = visibleTickets.length > 0;
|
|
byId('empty-title').textContent = folder === 'spam' ? 'Папка спама пуста' : 'Обращений пока нет';
|
|
byId('empty-note').hidden = folder === 'spam';
|
|
byId('table-container').hidden = visibleTickets.length === 0;
|
|
byId('counter').textContent = `Всего ${visibleTickets.length} · новых ${visibleTickets.filter((ticket) => ticket.status === 'new').length}`;
|
|
regularFilter.setAttribute('aria-pressed', String(folder === 'regular'));
|
|
spamFilter.setAttribute('aria-pressed', String(folder === 'spam'));
|
|
const spamCount = tickets.length - tickets.filter((ticket) => ticketFolder(ticket) === 'regular').length;
|
|
spamFilter.textContent = spamCount ? `Спам (${spamCount})` : 'Спам';
|
|
if (focusedId) ticketRows.querySelector(`[data-ticket="${focusedId}"]`)?.focus();
|
|
if (preserveDetail && selected) currentDetail?.update(selected);
|
|
else renderDetail(selected);
|
|
}
|
|
|
|
function openFolder(next) {
|
|
if (!config || folder === next) return;
|
|
folder = next;
|
|
selectedId = null;
|
|
feedback('queue-feedback');
|
|
renderQueue();
|
|
}
|
|
|
|
regularFilter.addEventListener('click', () => openFolder('regular'));
|
|
spamFilter.addEventListener('click', () => openFolder('spam'));
|
|
|
|
function refreshQueue(polling = false) {
|
|
const expected = generation;
|
|
const refresh = queueRefresh.catch(() => {}).then(async () => {
|
|
if (!isCurrent(expected)) return;
|
|
const { tickets: nextTickets } = await api('/api/tickets');
|
|
if (!isCurrent(expected)) return;
|
|
const changed = nextTickets.length !== tickets.length || nextTickets.some((ticket, index) => (
|
|
ticket.id !== tickets[index].id || ticket.updatedAt !== tickets[index].updatedAt
|
|
));
|
|
tickets = nextTickets;
|
|
if (!polling || changed) renderQueue(polling);
|
|
else currentDetail?.update(tickets.find((ticket) => ticket.id === selectedId));
|
|
});
|
|
queueRefresh = refresh;
|
|
return refresh;
|
|
}
|
|
|
|
async function pollQueue() {
|
|
const expected = generation;
|
|
await Promise.allSettled([refreshQueue(true), currentDetail?.refresh?.()]);
|
|
if (isCurrent(expected) && !clearing) scheduleQueuePoll();
|
|
}
|
|
|
|
function scheduleQueuePoll() {
|
|
window.clearTimeout(queueTimer);
|
|
queueTimer = window.setTimeout(pollQueue, 4000);
|
|
}
|
|
|
|
function resetPanel() {
|
|
attempt = null;
|
|
selectedId = null;
|
|
folder = 'regular';
|
|
tickets = [];
|
|
exampleField.value = '';
|
|
actorField.value = '';
|
|
messageField.value = '';
|
|
submit.textContent = 'Обработать и записать';
|
|
feedback('intake-feedback');
|
|
feedback('queue-feedback');
|
|
renderQueue();
|
|
}
|
|
|
|
clearButton.addEventListener('click', async () => {
|
|
if (exited || clearing || !window.confirm('Удалить все обращения и историю обоих чатов? Это действие нельзя отменить.')) return;
|
|
clearing = true;
|
|
window.clearTimeout(queueTimer);
|
|
clearButton.disabled = true;
|
|
exitButton.disabled = true;
|
|
panelView.inert = true;
|
|
feedback('queue-feedback');
|
|
try {
|
|
await api('/api/tickets/clear', 'POST', {});
|
|
invalidatePanel();
|
|
resetPanel();
|
|
setIntakeDisabled(false);
|
|
feedback('queue-feedback', 'Буфер очищен. Обращения и история чатов удалены.');
|
|
await refreshQueue().catch(() => {});
|
|
} catch (error) {
|
|
feedback('queue-feedback', error.message, true);
|
|
} finally {
|
|
clearing = false;
|
|
clearButton.disabled = false;
|
|
exitButton.disabled = false;
|
|
panelView.inert = false;
|
|
clearButton.focus();
|
|
scheduleQueuePoll();
|
|
}
|
|
});
|
|
|
|
exitButton.addEventListener('click', () => {
|
|
if (clearing) return;
|
|
exited = true;
|
|
invalidatePanel();
|
|
resetPanel();
|
|
panelView.hidden = true;
|
|
exitView.hidden = false;
|
|
exitButton.hidden = true;
|
|
byId('mode').hidden = true;
|
|
returnButton.focus();
|
|
});
|
|
|
|
returnButton.addEventListener('click', () => {
|
|
if (!exited) return;
|
|
exited = false;
|
|
panelView.hidden = false;
|
|
exitView.hidden = true;
|
|
exitButton.hidden = false;
|
|
byId('mode').hidden = false;
|
|
initialize();
|
|
exitButton.focus();
|
|
});
|
|
|
|
async function initialize() {
|
|
const expected = generation;
|
|
clearButton.disabled = true;
|
|
setIntakeDisabled(true);
|
|
byId('counter').textContent = 'Загрузка обращений';
|
|
try {
|
|
const [settings, sampleData] = await Promise.all([api('/api/config'), api('/api/examples')]);
|
|
if (!isCurrent(expected)) return;
|
|
config = settings;
|
|
examples = sampleData.examples;
|
|
byId('mode').textContent = modeLabel(config);
|
|
fillSelect(actorField, { '': 'Выберите тип автора', ...config.actors }, '');
|
|
messageField.maxLength = config.maxTextLength;
|
|
exampleField.replaceChildren(option('', 'Выбрать из списка'), ...examples.map((example, index) => option(String(index), example.title)));
|
|
setIntakeDisabled(false);
|
|
await refreshQueue();
|
|
if (isCurrent(expected)) {
|
|
clearButton.disabled = false;
|
|
scheduleQueuePoll();
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrent(expected)) return;
|
|
byId('counter').textContent = 'Очередь недоступна';
|
|
feedback('queue-feedback', error.message, true);
|
|
}
|
|
}
|
|
|
|
initialize();
|