Files
WhatIDo/public/js/lessons.js
T
dev b931c0a760 feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.

- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
  lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
  значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
  до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
  уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
  уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
  /versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
  статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
2026-10-04 00:12:24 +03:00

187 lines
8.2 KiB
JavaScript

const PAGE_SIZE = 20;
let page = 1;
let total = 0;
let searchTimer = null;
let groupsLoaded = false;
function lessonDateText(iso) {
const s = String(iso || '').slice(0, 10);
if (!s) return '';
const d = new Date(s + 'T00:00:00');
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function lessonWhenText(r) {
const date = lessonDateText(r.lesson_date);
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
return time ? `${date}, ${time}` : date;
}
function lessonAuthorText(r) {
const name = r.author_name || r.author_username;
if (!name) return '';
const upd = r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 60000;
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name;
}
function lessonAiBadgeHtml(r) {
const st = r.ai_status;
if (!st || st === 'none') return '';
if (st === 'pending' || st === 'processing') return `<span class="ai-badge ai-badge-ic ai-badge-processing" title="ИИ проверяет текст по шаблону"><i data-lucide="loader"></i></span>`;
if (st === 'done' && r.text_ai) return '<span class="ai-badge ai-badge-done" title="ИИ оформил текст по шаблону">ИИ</span>';
if (st === 'skipped') return '<span class="ai-badge ai-badge-skipped" title="Текст уже соответствует шаблону">По шаблону</span>';
if (st === 'reverted') return '<span class="ai-badge ai-badge-reverted" title="Возвращена исходная версия текста">Оригинал</span>';
if (st === 'error') return `<span class="ai-badge ai-badge-error" title="Проверка по шаблону не удалась: ${esc(r.ai_error || 'ошибка ИИ')}">Ошибка ИИ</span>`;
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" title="Статус ИИ: ${esc(st)}">${esc(st)}</span>`;
}
function lessonItemHtml(r) {
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
const author = lessonAuthorText(r);
const canRevert = !!r.text_original && r.text_original !== r.text;
return `
<div class="lesson-item" data-lesson="${r.id}">
<div class="lesson-item-head">
<span class="lesson-group">${esc(r.group_name || '')}</span>
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
${lessonAiBadgeHtml(r)}
<div class="lesson-actions">
<button type="button" class="btn-link" data-versions="${r.id}">История</button>
${canRevert ? `<button type="button" class="btn-link" data-revert="${r.id}" title="Вернуть исходный текст тьютора">↩ Оригинал</button>` : ''}
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
</div>
</div>
<div class="lesson-text">${esc(r.text || '')}</div>
${author ? `<div class="lesson-author">${esc(author)}</div>` : ''}
</div>`;
}
function renderLessons(items) {
const list = document.getElementById('lessonsList');
const state = document.getElementById('lessonsState');
list.innerHTML = items.length
? items.map(lessonItemHtml).join('')
: '<div class="empty">Отчётов пока нет</div>';
if (state) {
state.textContent = total ? `Показано ${items.length} из ${total}` : '';
}
renderIcons();
}
async function loadLessons() {
const q = new URLSearchParams();
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
const search = document.getElementById('searchInput').value.trim();
if (gid) q.set('group_id', gid);
if (df) q.set('date_from', df);
if (dt) q.set('date_to', dt);
if (search) q.set('search', search);
q.set('limit', String(PAGE_SIZE));
q.set('offset', String((page - 1) * PAGE_SIZE));
const list = document.getElementById('lessonsList');
try {
const res = await fetch(`${API}/api/lesson-reports?${q.toString()}`, { headers: hdr() });
if (!res.ok) {
list.innerHTML = `<div class="empty">Ошибка загрузки (${res.status})</div>`;
return;
}
const data = await res.json();
total = Number(data.total) || 0;
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (page > maxPage) { page = maxPage; return loadLessons(); }
renderLessons(data.items || []);
renderPager(document.getElementById('pager'), page, maxPage, p => { page = p; loadLessons(); });
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function loadGroupFilter() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
const groups = await loadLessonGroups();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id;
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function deleteLesson(id) {
if (!confirm('Удалить отчёт о занятии?')) return;
const res = await fetch(`${API}/api/lesson-reports/${id}`, { method: 'DELETE', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось удалить отчёт'); return; }
showToast('Отчёт удалён');
loadLessons();
}
async function revertLessonAi(id) {
if (!confirm('Вернуть исходный текст отчёта (до оформления по шаблону)?')) return;
const res = await fetch(`${API}/api/lesson-reports/${id}/ai/revert`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось вернуть оригинал'); return; }
showToast('Возвращён исходный текст отчёта');
loadLessons();
}
function onLessonReportSaved() {
loadLessons();
}
document.getElementById('lessonsList').addEventListener('click', e => {
const edit = e.target.closest('[data-edit]');
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; }
const versions = e.target.closest('[data-versions]');
if (versions) { openLessonVersions(versions.dataset.versions); return; }
const revert = e.target.closest('[data-revert]');
if (revert) { revertLessonAi(revert.dataset.revert); return; }
const del = e.target.closest('[data-del]');
if (del) { deleteLesson(del.dataset.del); }
});
document.getElementById('addLessonBtn').addEventListener('click', () => {
openLessonModal({ groupId: document.getElementById('groupFilter').value });
});
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350);
});
let lessonLiveSource = null;
function connectLessonLive() {
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
lessonLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
lessonLiveSource.addEventListener('lesson_report_status', () => loadLessons());
lessonLiveSource.onerror = () => {
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (lessonLiveSource) lessonLiveSource.close(); });
}
connectLessonLive();
(async () => {
if (!(await checkAuth())) return;
buildSidebar(document.body.dataset.page);
await loadGroupFilter();
loadLessons();
})();