Add EGE support prototype v0.1.0
This commit is contained in:
1 parent
9944a41cca
commit
24cae23262
19 files changed
+2581
-2
No files matched your search
+567
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user