Add EGE support prototype v0.1.0

This commit is contained in:
alexglados1 committed 2026-11-13 14:14:31 +03:00
1 parent 9944a41cca
commit 24cae23262
19 files changed
+2581 -2

No files matched your search

+567
View File
@@ -0,0 +1,567 @@
'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();
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Поддержка ЕГЭ · Обращения</title>
<link rel="stylesheet" href="/styles.css">
<script src="/app.js" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<span class="product">Поддержка ЕГЭ</span>
<div class="header-actions">
<span id="mode" class="mode">Загрузка</span>
<button id="panel-exit" class="header-button" type="button">Выход</button>
</div>
</div>
</header>
<main id="panel-view">
<h1>Обращения в поддержку</h1>
<nav class="page-actions" aria-label="Чаты поддержки">
<a class="secondary" href="/site-chat" target="_blank" rel="noopener">Чат пользователей</a>
<a class="secondary" href="/staff-chat" target="_blank" rel="noopener">Чат сотрудников</a>
</nav>
<div class="workspace">
<section class="panel intake" aria-labelledby="intake-title">
<h2 id="intake-title">Ручной ввод</h2>
<label for="example">Учебный пример</label>
<select id="example" disabled>
<option value="">Выбрать из списка</option>
</select>
<form id="intake-form">
<label for="actor">Кто обратился</label>
<select id="actor" name="actor" required disabled></select>
<label for="message">Текст сообщения</label>
<textarea id="message" name="message" rows="7" maxlength="4000" required placeholder="Вставьте текст из чата" disabled></textarea>
<p class="note">Используйте учебные или обезличенные сообщения.</p>
<button id="submit" type="submit" disabled>Обработать и записать</button>
</form>
<p class="feedback" id="intake-feedback" aria-live="polite"></p>
</section>
<section class="panel queue" aria-labelledby="queue-title">
<div class="queue-header">
<div>
<h2 id="queue-title">Очередь</h2>
<p class="note" id="counter">Загрузка обращений</p>
</div>
<div class="queue-actions">
<a class="secondary" href="/export.csv" download>Скачать CSV</a>
<button class="secondary clear-button" type="button" id="clear-buffer" disabled>Очистить буфер</button>
</div>
</div>
<div class="queue-filters" aria-label="Папки очереди" role="group">
<button class="queue-filter" type="button" id="regular-filter" aria-pressed="true">Обращения</button>
<button class="queue-filter" type="button" id="spam-filter" aria-pressed="false">Спам</button>
</div>
<div id="empty" class="empty" hidden>
<p id="empty-title">Обращений пока нет</p>
<span id="empty-note">Они появятся после отправки из чата или ручного ввода.</span>
</div>
<div class="table-scroll" id="table-container" hidden>
<table role="table" aria-label="Журнал обращений">
<thead>
<tr><th scope="col">Обращение</th><th scope="col">Категория</th><th scope="col">Очередь</th><th scope="col">Статус</th></tr>
</thead>
<tbody id="ticket-rows"></tbody>
</table>
</div>
<div id="ticket-detail" class="ticket-detail" hidden></div>
<p class="feedback" id="queue-feedback" aria-live="polite"></p>
</section>
</div>
</main>
<main id="panel-exit-view" class="exit-view" hidden>
<section class="panel">
<h1>Вы вышли</h1>
<button type="button" id="panel-return">Вернуться к работе</button>
</section>
</main>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Чат поддержки ЕГЭ</title>
<link rel="stylesheet" href="/styles.css">
<script src="/site-chat.js" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<span class="product">Онлайн-школа ЕГЭ</span>
<button id="chat-exit" class="header-button" type="button">Выход</button>
</div>
</header>
<main class="site-shell" id="chat-view">
<h1 id="chat-title">Чат поддержки</h1>
<section class="panel chat-panel" aria-label="Чат поддержки">
<div class="chat-panel-header">
<p id="chat-ticket" class="note">Новое обращение</p>
<button type="button" id="chat-new" class="secondary" hidden>Новое обращение</button>
</div>
<div class="chat-window" id="chat-window">
<p id="chat-empty" class="chat-empty">Опишите вопрос. Детали можно отправить следующими сообщениями.</p>
<ol id="chat-log" class="chat-log" role="log" aria-label="Переписка" aria-relevant="additions" hidden></ol>
</div>
<form id="chat-form">
<label for="chat-message">Сообщение</label>
<div class="chat-compose">
<textarea id="chat-message" name="message" rows="3" maxlength="4000" required placeholder="Что случилось?"></textarea>
<button id="chat-submit" type="submit">Отправить</button>
</div>
</form>
<p id="chat-feedback" class="feedback" role="status"></p>
</section>
</main>
<main id="chat-exit-view" class="exit-view" hidden>
<section class="panel">
<h1>Вы вышли</h1>
<button type="button" id="chat-return">Вернуться в чат</button>
</section>
</main>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
'use strict';
const form = document.getElementById('chat-form');
const message = document.getElementById('chat-message');
const submit = document.getElementById('chat-submit');
const feedback = document.getElementById('chat-feedback');
const messages = document.getElementById('chat-log');
const chatWindow = document.getElementById('chat-window');
const chatEmpty = document.getElementById('chat-empty');
const ticketLabel = document.getElementById('chat-ticket');
const newConversation = document.getElementById('chat-new');
const chatView = document.getElementById('chat-view');
const exitView = document.getElementById('chat-exit-view');
const exitButton = document.getElementById('chat-exit');
const returnButton = document.getElementById('chat-return');
const staff = location.pathname === '/staff-chat';
const channel = staff ? 'staff' : 'users';
const sessionsUrl = `/api/chat/${channel}/sessions`;
const storageKey = `ege-support.chat.${channel}`;
const sessionIdPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu;
const roleLabels = { customer: 'Вы', staff: 'Вы', moderator: 'Модерация', operator: 'Поддержка' };
const timeFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
let conversation = loadConversation();
let attempt;
let sending = false;
let renderedCount = 0;
let exited = false;
let generation = 0;
let pollTimer;
function isCurrent(expected) {
return !exited && generation === expected;
}
const title = staff ? 'Чат сотрудников' : 'Чат пользователей';
document.getElementById('chat-title').textContent = title;
document.title = `${title} · Онлайн-школа ЕГЭ`;
function loadConversation() {
if (staff) {
const params = new URLSearchParams(location.hash.slice(1));
if (params.has('session')) {
const id = params.get('session');
history.replaceState(null, '', location.pathname + location.search);
if (sessionIdPattern.test(id)) {
const issued = { id, fromStaffSite: true };
storeConversation(issued);
return issued;
}
}
}
try {
const saved = JSON.parse(localStorage.getItem(storageKey));
if (typeof saved?.id === 'string' && sessionIdPattern.test(saved.id)) return saved;
} catch {
// The chat can still be used for the current tab when storage is unavailable.
}
return null;
}
function storeConversation(value) {
try {
if (value) localStorage.setItem(storageKey, JSON.stringify(value));
else localStorage.removeItem(storageKey);
} catch {
// Keep the active conversation in memory when storage is unavailable.
}
}
function setFeedback(text = '', error = false) {
feedback.textContent = text;
feedback.dataset.error = error;
}
async function request(url, body) {
const options = body === undefined ? {} : {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
let response;
try {
response = await fetch(url, options);
} catch {
throw new Error('Не удалось связаться с сервером. Повторите попытку.');
}
const data = await response.json().catch(() => null);
if (!response.ok || !data) {
const error = new Error(data?.error || 'Не удалось выполнить действие. Повторите попытку.');
error.status = response.status;
throw error;
}
return data;
}
function renderConversation(data, follow = false) {
if (data.id !== conversation?.id || data.messages.length < renderedCount) return;
const wasAtBottom = chatWindow.scrollHeight - chatWindow.scrollTop - chatWindow.clientHeight < 40;
const fragment = document.createDocumentFragment();
for (const item of data.messages.slice(renderedCount)) {
const bubble = document.createElement('li');
bubble.dataset.role = item.role;
const meta = document.createElement('div');
meta.className = 'chat-meta';
const author = document.createElement('strong');
author.textContent = roleLabels[item.role] || 'Участник';
const time = document.createElement('time');
time.dateTime = item.createdAt;
time.textContent = timeFormat.format(new Date(item.createdAt));
const text = document.createElement('p');
text.textContent = item.text;
meta.append(author, time);
bubble.append(meta, text);
fragment.append(bubble);
}
messages.append(fragment);
renderedCount = data.messages.length;
messages.hidden = renderedCount === 0;
chatEmpty.hidden = renderedCount > 0;
ticketLabel.textContent = data.ticketId ? `Обращение №${data.ticketId}` : 'Новое обращение';
newConversation.hidden = !data.ticketId || Boolean(conversation.fromStaffSite);
if (follow || wasAtBottom) chatWindow.scrollTop = chatWindow.scrollHeight;
}
function resetConversation() {
conversation = null;
attempt = null;
renderedCount = 0;
storeConversation(null);
messages.replaceChildren();
messages.hidden = true;
chatEmpty.hidden = false;
newConversation.hidden = true;
ticketLabel.textContent = 'Новое обращение';
}
async function refreshConversation() {
if (exited || !conversation || sending) return;
const current = conversation;
try {
const data = await request(`${sessionsUrl}/${current.id}`);
if (conversation === current) renderConversation(data);
} catch (error) {
if (conversation !== current || sending) return;
if (error.status === 404) {
resetConversation();
setFeedback(current.fromStaffSite
? 'Откройте новую ссылку на чат в разделе поддержки сотрудников.'
: 'Предыдущая переписка недоступна. Можно начать новое обращение.', true);
} else if (!renderedCount) {
setFeedback(error.message, true);
}
}
}
async function pollConversation() {
const expected = generation;
await refreshConversation();
if (isCurrent(expected)) pollTimer = window.setTimeout(pollConversation, 2000);
}
function setSending(value) {
sending = value;
submit.disabled = value;
message.disabled = value;
newConversation.disabled = value;
form.setAttribute('aria-busy', String(value));
submit.textContent = value ? 'Отправляем…' : 'Отправить';
}
newConversation.addEventListener('click', () => {
resetConversation();
message.value = '';
setFeedback();
message.focus();
});
exitButton.addEventListener('click', () => {
exited = true;
generation += 1;
window.clearTimeout(pollTimer);
resetConversation();
message.value = '';
setFeedback();
setSending(false);
chatView.hidden = true;
exitView.hidden = false;
exitButton.hidden = true;
returnButton.focus();
});
returnButton.addEventListener('click', () => {
if (!exited) return;
exited = false;
chatView.hidden = false;
exitView.hidden = true;
exitButton.hidden = false;
message.focus();
pollConversation();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (exited) return;
const expected = generation;
const text = message.value.trim();
if (!text || sending) {
if (!text) setFeedback('Введите сообщение.', true);
return;
}
if (!attempt || attempt.text !== text) attempt = { requestId: crypto.randomUUID(), text };
setSending(true);
setFeedback();
try {
if (!conversation) {
const created = await request(sessionsUrl, {});
if (!isCurrent(expected)) return;
conversation = { id: created.id };
storeConversation(conversation);
}
const updated = await request(`${sessionsUrl}/${conversation.id}/messages`, attempt);
if (!isCurrent(expected)) return;
renderConversation(updated, true);
message.value = '';
attempt = null;
setFeedback('Сообщение отправлено.');
} catch (error) {
if (!isCurrent(expected)) return;
if (error.status === 404 && conversation) {
const needsLink = conversation.fromStaffSite;
resetConversation();
setFeedback(needsLink
? 'Откройте новую ссылку на чат в разделе поддержки сотрудников.'
: 'Предыдущая переписка недоступна. Повторите отправку, чтобы начать новую.', true);
} else if (staff && (error.status === 401 || error.status === 403)) {
setFeedback('Откройте чат через раздел поддержки сотрудников на сайте.', true);
} else {
setFeedback(error.message, true);
}
} finally {
if (isCurrent(expected)) {
setSending(false);
message.focus();
}
}
});
pollConversation();
+126
View File
@@ -0,0 +1,126 @@
:root {
color-scheme: light;
font-family: Inter, 'Segoe UI', sans-serif;
color: #142921;
background: #f3f5ef;
font-size: 16px;
line-height: 1.5;
--green: #15583e;
--dark-green: #103a2c;
--border: #d9e2d9;
--muted: #4f6257;
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; }
.topbar { background: var(--dark-green); color: #fff; }
.topbar-inner { max-width: 1440px; padding: 15px clamp(16px, 4vw, 56px); margin: auto; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.product { font-weight: 750; letter-spacing: .015em; }
.header-actions, .queue-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: 8px; }
.mode { font-size: .82rem; border: 1px solid #7da894; color: #f1fff4; padding: 3px 10px; border-radius: 20px; text-align: center; }
main { max-width: 1440px; padding: clamp(24px, 4vw, 54px) clamp(16px, 4vw, 56px); margin: auto; }
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: clamp(2rem, 4.4vw, 3.25rem); font-weight: 770; letter-spacing: -.045em; line-height: 1.06; margin-bottom: 12px; }
h2 { font-size: 1.2rem; letter-spacing: -.015em; margin-bottom: 16px; }
h3 { font-size: 1.15rem; margin-bottom: 8px; }
.page-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
.workspace { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); gap: 20px; align-items: start; }
.panel { background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: clamp(15px, 2vw, 26px); }
label { display: block; font-size: .87rem; font-weight: 650; margin-bottom: 5px; }
select, textarea, button { font: inherit; }
select, textarea { display: block; width: 100%; padding: 8px 11px; border: 1px solid #a7b9ad; border-radius: 7px; background: #fff; color: inherit; margin-bottom: 12px; }
select { min-height: 43px; }
textarea { resize: vertical; min-height: 90px; }
select:disabled, textarea:disabled { color: var(--muted); background: #f6f8f3; }
textarea::placeholder { color: #64796b; }
button, .secondary { border-radius: 7px; padding: 8px 13px; text-decoration: none; cursor: pointer; font-weight: 700; font-size: .88rem; }
button { background: var(--green); color: #fff; border: 1px solid var(--green); }
button:hover:not(:disabled) { background: var(--dark-green); }
button:disabled { cursor: wait; opacity: .64; }
.header-button { color: #fff; background: transparent; border-color: #7da894; }
.header-button:hover:not(:disabled) { background: #265b46; }
button:focus-visible, a:focus-visible, select:focus-visible, textarea:focus-visible, input:focus-visible { outline: 3px solid #45816b; outline-offset: 3px; }
.intake button { width: 100%; }
.secondary { background: #fff; color: var(--green); border: 1px solid #a7b9ad; white-space: nowrap; }
.secondary:hover, button.secondary:hover:not(:disabled) { background: #eef4ec; }
.note, .feedback { color: var(--muted); font-size: .84rem; }
.note { margin-bottom: 12px; }
.feedback { margin: 10px 0 0; }
.feedback:empty { display: none; }
.feedback[data-error="true"] { color: #942c20; }
.queue-header { display: flex; justify-content: space-between; align-items: start; gap: 8px; margin-bottom: 12px; }
.queue-header h2 { margin-bottom: 1px; }
.queue-header p { margin: 0; }
.clear-button { color: #9a3223; border-color: #d6a99f; }
button.clear-button:hover:not(:disabled) { background: #fbeeea; }
.queue-filters { display: flex; gap: 6px; margin-bottom: 12px; }
.queue-filter { background: #fff; color: var(--green); border-color: var(--border); }
.queue-filter:hover:not(:disabled) { background: #eef4ec; }
.queue-filter[aria-pressed="true"], .queue-filter[aria-pressed="true"]:hover:not(:disabled) { background: var(--green); color: #fff; border-color: var(--green); }
.empty { padding: 27px 16px; border: 1px dashed #adbdad; background: #f7faf4; border-radius: 8px; text-align: center; }
.empty p { font-weight: 700; margin: 0 0 3px; }
.empty span { color: var(--muted); font-size: .87rem; }
.table-scroll { overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 485px; text-align: left; font-size: .85rem; }
thead { background: #eef4eb; }
th, td { padding: 9px 10px; vertical-align: top; }
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:has(button[aria-expanded="true"]) { background: #f2f7ef; }
.ticket-button { padding: 0; border: 0; background: transparent; color: var(--green); font-size: .9rem; text-align: left; overflow-wrap: anywhere; }
.ticket-button:hover:not(:disabled) { background: transparent; color: var(--dark-green); text-decoration: underline; }
.ticket-meta { display: block; color: var(--muted); margin-top: 3px; }
.tag { display: inline-block; border-radius: 20px; white-space: nowrap; padding: 2px 7px; font-weight: 650; background: #e9f3e7; color: #20533a; }
.tag[data-category="urgent"] { background: #fce9e2; color: #872f23; }
.tag[data-category="spam"] { background: #edf0ee; color: #45544b; }
.ticket-detail { padding: 16px; margin-top: 14px; border: 1px solid var(--border); background: #fbfdf9; border-radius: 8px; }
.detail-meta { color: var(--muted); font-size: .85rem; }
.source-id { overflow-wrap: anywhere; }
.detail-text { overflow-wrap: anywhere; white-space: pre-wrap; margin-bottom: 12px; }
.review { color: #76471a; background: #fff4d7; padding: 5px 8px; border-radius: 5px; width: fit-content; }
.controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.checkbox { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; }
.checkbox input { accent-color: var(--green); }
.site-shell { max-width: 760px; }
.site-shell h1 { margin-bottom: 20px; }
.chat-panel-header, .chat-compose, .reply-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.chat-panel-header { min-height: 39px; margin-bottom: 10px; }
.chat-panel-header p { margin: 0; font-weight: 650; }
.chat-window { min-height: 145px; max-height: 48vh; overflow: auto; overscroll-behavior: contain; background: #f6f8f4; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 15px; }
.chat-empty { color: var(--muted); margin: 0; padding: 25px 15px; }
.chat-log { display: grid; gap: 10px; margin: 0; padding: 12px; list-style: none; }
.chat-log li { justify-self: start; max-width: 88%; min-width: 0; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: #fff; overflow-wrap: anywhere; }
.chat-log li[data-role="customer"], .chat-log li[data-role="staff"] { justify-self: end; background: #e7f3e9; }
.chat-log li[data-role="moderator"] { background: #fff7e6; border-color: #e8d9b5; }
.chat-log p { margin: 3px 0 0; white-space: pre-wrap; }
.chat-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 3px 12px; color: var(--muted); font-size: .8rem; }
.chat-meta strong { color: #234539; }
.chat-compose { align-items: end; }
.chat-compose textarea { margin-bottom: 0; }
.ticket-chat { margin: 15px 0; padding-top: 13px; border-top: 1px solid var(--border); }
.ticket-chat h4 { margin: 0 0 8px; }
.ticket-chat .chat-window { min-height: 0; max-height: 340px; }
.ticket-chat .chat-log li { justify-self: start; }
.ticket-chat .chat-log li[data-role="operator"] { justify-self: end; }
.reply-actions { justify-content: start; flex-wrap: wrap; }
.exit-view { max-width: 520px; }
.exit-view h1 { margin-bottom: 20px; }
@media (max-width: 880px) {
.workspace { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 540px) {
.chat-compose { flex-direction: column; align-items: stretch; }
.chat-log li { max-width: 96%; }
table { min-width: 0; }
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
tbody { display: block; }
tbody tr { display: grid; grid-template-columns: max-content minmax(0, 1fr) minmax(0, auto); gap: 5px 9px; padding: 10px 8px; }
td { padding: 0; overflow-wrap: anywhere; }
td:first-child { grid-column: 1 / -1; }
td:last-child { text-align: right; }
}
@media (max-width: 400px) {
.queue-header { align-items: stretch; flex-direction: column; }
.queue-actions { justify-content: start; }
}