function $id(id) { return document.getElementById(id); } function fmtSeconds(v) { var m = Math.floor(v / 60); var s = v % 60; return m + ':' + (s < 10 ? '0' : '') + s; } /* ---------- Оценка + комментарии ---------- */ function openGradeEditor(btn) { var all = []; try { all = JSON.parse(btn.getAttribute('data-comments') || '[]'); } catch (e) { all = []; } var idx = parseInt(btn.getAttribute('data-ci') || '0', 10); var list = Array.isArray(all[idx]) ? all[idx] : []; $id('grade-student').value = btn.getAttribute('data-student') || ''; $id('grade-lab').value = btn.getAttribute('data-lab') || ''; $id('grade-who').value = btn.getAttribute('data-student-name') || ''; var title = $id('grade-title'); if (title) title.textContent = (btn.getAttribute('data-lab-name') || '') + ' — ' + (btn.getAttribute('data-student-name') || ''); $id('grade-score').value = btn.getAttribute('data-score') || ''; var box = $id('cs-rows'); box.innerHTML = ''; list.forEach(function (c) { csRow(c.text, c.tone); }); if (!list.length) csRow('', 'neutral'); var d = $id('modal-grade'); if (d && d.showModal) d.showModal(); } function csRow(text, tone) { var box = $id('cs-rows'); var row = document.createElement('div'); row.className = 'cs-row'; var wrap = document.createElement('div'); wrap.className = 'cs-text-wrap'; var inp = document.createElement('input'); inp.className = 'input cs-text'; inp.placeholder = 'Комментарий'; inp.value = text || ''; wrap.appendChild(inp); var sel = document.createElement('select'); sel.className = 'input cs-tone'; sel.title = 'Тональность комментария'; [['positive', '+ позитивный'], ['negative', '— негативный'], ['neutral', '○ нейтральный']].forEach(function (o) { var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op); }); sel.value = tone || 'neutral'; var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-secondary'; rm.textContent = 'удалить'; rm.onclick = function () { row.remove(); }; row.appendChild(wrap); row.appendChild(sel); row.appendChild(rm); box.appendChild(row); attachCommentAutocomplete(inp); } function csSerialize() { var out = []; document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) { var t = r.querySelector('.cs-text').value.trim(); if (t) out.push({ text: t, tone: r.querySelector('.cs-tone').value }); }); $id('grade-comments').value = JSON.stringify(out); } /* ---------- Автодополнение комментариев ---------- * При фокусе/вводе запрашивает ранее введённые комментарии (подстрока, без регистра), * отсортированные по частоте; выбор подставляет текст и «тон». */ var AC_TIMER = null; function attachCommentAutocomplete(input) { var list = document.createElement('div'); list.className = 'ac-list'; list.hidden = true; input.parentNode.appendChild(list); var active = -1; function hide() { list.hidden = true; active = -1; } function render(items) { if (document.activeElement !== input) return; if (!Array.isArray(items) || !items.length) { hide(); return; } list.innerHTML = ''; items.forEach(function (item) { var count = parseInt(item.count || 0, 10) || 0; var tone = String(item.tone || 'neutral').toLowerCase(); var toneKey = tone.indexOf('pos') === 0 ? 'pos' : (tone.indexOf('neg') === 0 ? 'neg' : 'neu'); var row = document.createElement('div'); row.className = 'ac-item ac-' + toneKey; var mark = document.createElement('span'); mark.className = 'ac-tone ac-tone-' + toneKey; mark.textContent = toneKey === 'pos' ? '+' : (toneKey === 'neg' ? '—' : '○'); mark.title = toneKey === 'pos' ? 'Позитивный' : (toneKey === 'neg' ? 'Негативный' : 'Нейтральный'); row.appendChild(mark); var text = document.createElement('span'); text.className = 'ac-text'; text.textContent = item.text; row.appendChild(text); var badge = document.createElement('span'); badge.className = 'badge badge-xs ac-count'; badge.textContent = count; badge.title = 'Использований: ' + count; row.appendChild(badge); var del = document.createElement('button'); del.type = 'button'; del.className = 'ac-del'; del.textContent = '✕'; del.disabled = count > 0; del.title = count > 0 ? 'Нельзя удалить: комментарий используется ' + count + ' раз(а)' : 'Удалить комментарий из подсказок'; del.addEventListener('mousedown', function (event) { event.preventDefault(); event.stopPropagation(); }); del.addEventListener('click', function (event) { event.stopPropagation(); if (count > 0) return; removeCommentTemplate(item.text, load); }); row.appendChild(del); row.addEventListener('mousedown', function (event) { if (event.target.closest('.ac-del')) return; event.preventDefault(); input.value = item.text; var rowEl = input.closest('.cs-row'); var tone = rowEl ? rowEl.querySelector('.cs-tone') : null; if (tone && item.tone) tone.value = item.tone; hide(); }); list.appendChild(row); }); list.hidden = false; active = -1; positionList(); } /* Если снизу внутри модалки мало места — раскрываем список вверх, чтобы он не обрезался. */ function positionList() { var wrap = input.parentNode; var scrollParent = input.closest('.dlg-body') || document.documentElement; var wr = wrap.getBoundingClientRect(); var sr = scrollParent.getBoundingClientRect(); var below = sr.bottom - wr.bottom; var above = wr.top - sr.top; list.classList.toggle('drop-up', below < 220 && above > below); } window.addEventListener('resize', function () { if (!list.hidden) positionList(); }); function load() { clearTimeout(AC_TIMER); AC_TIMER = setTimeout(function () { fetch('/admin/comment-templates?q=' + encodeURIComponent(input.value.trim())) .then(function (r) { return r.ok ? r.json() : []; }) .then(render) .catch(hide); }, 150); } input.addEventListener('focus', load); input.addEventListener('input', load); input.addEventListener('blur', function () { setTimeout(hide, 150); }); input.addEventListener('keydown', function (event) { if (list.hidden) return; var items = list.querySelectorAll('.ac-item'); if (!items.length) return; if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); active = (active + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; items.forEach(function (el, i) { el.classList.toggle('active', i === active); }); } else if (event.key === 'Enter' && active >= 0) { event.preventDefault(); items[active].dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); } else if (event.key === 'Escape') { hide(); } }); } /* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */ function showToast(text, kind) { var host = $id('toast-host'); if (!host) return; var toast = document.createElement('div'); toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success'); toast.setAttribute('data-toast', ''); var span = document.createElement('span'); span.className = 'toast-text'; span.textContent = text; toast.appendChild(span); var close = document.createElement('button'); close.type = 'button'; close.className = 'toast-close'; close.textContent = '✕'; close.addEventListener('click', function () { toast.remove(); }); toast.appendChild(close); host.appendChild(toast); setTimeout(function () { toast.remove(); }, 5000); } function removeCommentTemplate(text, done) { var form = $id('grade-form'); var csrf = form ? form.querySelector('input[name="_csrf"]') : null; var body = '_csrf=' + encodeURIComponent(csrf ? csrf.value : '') + '&text=' + encodeURIComponent(text); fetch('/admin/comment-templates/delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }) .then(function (r) { return r.ok ? r.json() : { ok: false }; }) .then(function (res) { if (res && res.ok) { showToast('Комментарий удалён из подсказок.'); if (done) done(); } else { showToast((res && res.error) || 'Не удалось удалить комментарий.', 'error'); } }) .catch(function () { showToast('Не удалось удалить комментарий.', 'error'); }); } /* ---------- Публикация оценки и комментариев отдельному студенту ---------- */ function attachPublishToggles() { var urlEl = $id('pub-url'); if (!urlEl) return; var csrfEl = document.querySelector('input[name="_csrf"]'); document.querySelectorAll('.pub-toggle').forEach(function (cb) { var labId = cb.getAttribute('data-lab') || ''; cb.addEventListener('change', function () { var desired = cb.checked; var body = new URLSearchParams(); body.set('_csrf', csrfEl ? csrfEl.value : ''); body.set('labId', labId); body.set('studentId', cb.getAttribute('data-student') || ''); body.set('published', desired ? 'true' : 'false'); cb.disabled = true; fetch(urlEl.value, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }) .then(function (r) { return r.ok ? r.json() : { ok: false }; }) .then(function (res) { cb.disabled = false; if (res && res.ok) { showToast((desired ? 'Опубликовано: ' : 'Скрыто: ') + res.count + ' из ' + res.total); var eye = document.querySelector('.mini-btn.js-pub-eye[data-lab="' + labId + '"]'); if (eye) { eye.setAttribute('data-count', String(res.count || 0)); eye.setAttribute('data-total', String(res.total || 0)); eye.setAttribute('data-all', res.all ? 'true' : 'false'); updateEyeCounts(); } } else { cb.checked = !desired; showToast((res && res.error) || 'Не удалось изменить публикацию.', 'error'); } }) .catch(function () { cb.disabled = false; cb.checked = !desired; showToast('Не удалось изменить публикацию.', 'error'); }); }); }); } /* Перерисовка «глазика» публикации по атрибутам data-count/data-total/data-all. */ function updateEyeCounts() { document.querySelectorAll('.mini-btn.js-pub-eye').forEach(function (eye) { var total = parseInt(eye.getAttribute('data-total') || '0', 10); var count = parseInt(eye.getAttribute('data-count') || '0', 10); var all = eye.getAttribute('data-all') === 'true'; eye.classList.toggle('is-empty', count === 0); eye.classList.toggle('is-all', all); eye.classList.toggle('is-partial', count > 0 && !all); var c = eye.querySelector('.js-pub-count'); if (c) { c.textContent = count; c.classList.toggle('is-hidden', count === 0 || all); } eye.title = all ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (count > 0 ? 'Опубликовано ' + count + ' из ' + total + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'); var form = eye.closest('form'); var hidden = form ? form.querySelector('.js-pub-published') : null; if (hidden) hidden.value = all ? 'false' : 'true'; }); } /* ---------- За Protection: синхронизация таймеров со статусами с сервером (10 с) ---------- */ var DEFENSE_STATE = {}; var DEFENSE_POLL_MS = 10000; var DEFENSE_TICK_MS = 1000; function defenseBtn(sessionId) { if (!sessionId) return null; return document.querySelector('.cell-btn.js-defense-cell[data-session="' + sessionId + '"]'); } function applyDefenseToCell(s) { var btn = defenseBtn(s.sessionId); if (!btn) return; btn.setAttribute('data-status', s.status || ''); if (s.status === 'started' && s.endsAt) btn.setAttribute('data-seconds', String(Math.max(0, Math.ceil((s.endsAt - Date.now()) / 1000)))); if (s.code) btn.setAttribute('data-code', s.code); else btn.removeAttribute('data-code'); btn.classList.remove('ok', 'warn', 'dim'); btn.classList.add(s.status === 'ready' ? 'ok' : (s.status === 'waiting' || s.status === 'started' ? 'warn' : 'dim')); var label = btn.querySelector('.js-def-label'); var timer = btn.querySelector('.js-def-timer'); var started = s.status === 'started'; if (!timer && started) { timer = document.createElement('span'); timer.className = 'defense-cell-timer js-def-timer'; btn.appendChild(timer); } if (timer) { timer.hidden = !started; if (started) timer.textContent = fmtSeconds(Math.max(0, Math.ceil((s.endsAt - Date.now()) / 1000))); } if (label) { label.textContent = started ? '' : (s.status == null ? '—' : (s.status === 'ready' ? 'готова' : (s.code ? 'код ' + s.code : 'ожидает'))); } if (s.status === 'ready') { btn.title = 'Защита: ' + defenseReasonText(s.readyReason); if (label) label.textContent = 'готова'; } } function syncDefenseStatuses(sessions, initial) { (sessions || []).forEach(function (s) { var prev = DEFENSE_STATE[s.sessionId]; var st = { sessionId: s.sessionId, status: s.status, code: s.code, secondsLeft: s.secondsLeft, readyReason: s.readyReason, endsAt: s.status === 'started' ? Date.now() + Math.max(0, parseInt(s.secondsLeft || 0, 10) * 1000) : null }; DEFENSE_STATE[s.sessionId] = st; applyDefenseToCell(st); // Уведомление о переходе в «готов» (не при первой загрузке). if (!initial && prev && prev.status === 'started' && s.status === 'ready') { var btn = defenseBtn(s.sessionId); var who = (btn && btn.getAttribute('data-student-name')) || 'студент'; var lab = (btn && btn.getAttribute('data-lab-name')) || 'лабораторная'; var reason = s.readyReason === 'timeout' ? 'время вышло' : 'нажал «Готов»'; showToast(who + ' — ' + lab + ': ' + reason + '.'); } // Обновляем открытый диалог, если он про эту сессию. var dlg = document.getElementById('modal-defense'); if (dlg && dlg.open && DEFENSE_OPEN_SESSION === s.sessionId) { var statusEl = $id('defense-status'); if (statusEl) { statusEl.textContent = defenseStatusText(s.status, s.code, s.status === 'started' ? Math.max(0, Math.ceil((st.endsAt - Date.now()) / 1000)) : 0, s.readyReason); } var resetForm = $id('defense-reset-form'); if (resetForm) resetForm.hidden = s.status !== 'ready'; } }); } function startDefenseWatch() { var meta = $id('defense-meta'); if (!meta) return; var labs = meta.getAttribute('data-labs') || ''; if (!labs) return; var url = '/admin/defense-watch?labs=' + encodeURIComponent(labs); var first = true; function poll() { fetch(url) .then(function (r) { return r.ok ? r.json() : { ok: false }; }) .then(function (res) { if (res && res.ok) { syncDefenseStatuses(res.sessions, first); first = false; } }) .catch(function () {}); } poll(); setInterval(poll, DEFENSE_POLL_MS); setInterval(function () { Object.keys(DEFENSE_STATE).forEach(function (id) { var s = DEFENSE_STATE[id]; if (s.status === 'started') applyDefenseToCell(s); }); }, DEFENSE_TICK_MS); } /* ---------- Индивидуальное задание (защита) ---------- */ function defenseReasonText(reason) { if (reason === 'timeout') return 'Вышло время'; return 'Готов к сдаче'; } function defenseStatusText(status, code, seconds, reason) { if (status === 'waiting') { return code ? 'Задание назначено. Код входа: ' + code + ' — ждём входа студента.' : 'Задание назначено, код ещё не выдан.'; } if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.'; if (status === 'ready') { return defenseReasonText(reason) + '. Можно начать заново для дополнительного задания.'; } return ''; } /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */ var DEFENSE_OPEN_SESSION = ''; function openDefense(btn) { var form = $id('defense-form'); if (!form) return; form.reset(); var student = btn.getAttribute('data-student') || ''; var lab = btn.getAttribute('data-lab') || ''; if (student) $id('defense-student-select').value = student; if (lab) $id('defense-lab-select').value = lab; $id('defense-md').value = btn.getAttribute('data-md') || ''; var session = btn.getAttribute('data-session') || ''; var status = btn.getAttribute('data-status') || ''; var code = btn.getAttribute('data-code') || ''; var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10); var reason = btn.getAttribute('data-reason') || ''; var actions = $id('defense-actions'); var statusEl = $id('defense-status'); DEFENSE_OPEN_SESSION = session; if (session) { $id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code'; $id('defense-reset-form').action = '/admin/defenses/' + session + '/reset'; $id('defense-action-lab').value = lab; $id('defense-reset-lab').value = lab; $id('defense-reset-form').hidden = status !== 'ready'; if (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds, reason); actions.hidden = false; } else { if (statusEl) statusEl.textContent = ''; actions.hidden = true; } var d = $id('modal-defense'); if (d && d.showModal) d.showModal(); } /* ---------- Назначение варианта (вручную или случайно из уровня) ---------- */ var VARIANT_CACHE = {}; function openVariant(btn) { var student = btn.getAttribute('data-student') || ''; var lab = btn.getAttribute('data-lab') || ''; var who = btn.getAttribute('data-student-name') || ''; var labName = btn.getAttribute('data-lab-name') || ''; var currentVariant = btn.getAttribute('data-variant') || ''; $id('variant-who').textContent = who + ' — ' + labName; $id('variant-random-student').value = student; $id('variant-random-lab').value = lab; $id('variant-pin-student').value = student; $id('variant-pin-lab').value = lab; var levelSel = $id('variant-level'); levelSel.innerHTML = ''; VARIANT_CACHE = {}; fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema') .then(function (r) { return r.ok && !r.redirected ? r.json() : { levels: [] }; }) .catch(function () { window.showAlert({ title: 'Не удалось открыть', text: 'Не удалось загрузить уровни и варианты (возможно, сессия истекла). Обновите страницу и откройте модалку заново.' }); return { levels: [] }; }) .then(function (data) { (data.levels || []).forEach(function (lvl, i) { var opt = document.createElement('option'); opt.value = lvl.id; opt.textContent = lvl.title || ('Уровень ' + (i + 1)); opt.__variants = lvl.variants || []; (lvl.variants || []).forEach(function (v) { VARIANT_CACHE[v.id] = v; }); levelSel.appendChild(opt); }); // если вариант уже назначен — открываем уровень, в котором он лежит if (currentVariant) { Array.prototype.some.call(levelSel.options, function (opt) { if ((opt.__variants || []).some(function (v) { return v.id === currentVariant; })) { levelSel.value = opt.value; return true; } return false; }); } levelSel.onchange = function () { fillVariantSelect(currentVariant); }; fillVariantSelect(currentVariant); }); var d = $id('modal-variant'); if (d && d.showModal) d.showModal(); } function fillVariantSelect(currentVariant) { var levelSel = $id('variant-level'); var variantSel = $id('variant-variant'); variantSel.innerHTML = ''; var opt = levelSel.selectedOptions[0]; var variants = (opt && opt.__variants) || []; variants.forEach(function (v) { var o = document.createElement('option'); o.value = v.id; o.textContent = 'Вариант ' + v.number; if (currentVariant && v.id === currentVariant) o.selected = true; variantSel.appendChild(o); }); $id('variant-random-level').value = levelSel.value || ''; $id('variant-pin-id').value = variantSel.value || ''; variantSel.onchange = function () { $id('variant-pin-id').value = variantSel.value || ''; showVariantTask(); }; showVariantTask(); } function showVariantTask() { var variantSel = $id('variant-variant'); var wrap = $id('variant-task-wrap'); var box = $id('variant-task'); if (!wrap || !box) return; var sel = variantSel.selectedOptions[0]; var v = sel && VARIANT_CACHE[sel.value]; if (v && v.statementHtml) { box.innerHTML = v.statementHtml; wrap.hidden = false; } else { box.innerHTML = ''; wrap.hidden = true; } } /* ---------- Матрица: фокус на лабораторной ---------- */ function focusLab() { var s = $id('lab-focus'); if (!s) return; var labId = s.value; document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab], .filter-row th[data-lab]').forEach(function (el) { el.toggleAttribute('hidden', !!labId && el.getAttribute('data-lab') !== labId); }); } document.addEventListener('submit', function (ev) { if (ev.target && ev.target.id === 'grade-form') csSerialize(); }, true); window.addEventListener('DOMContentLoaded', function () { focusLab(); startDefenseWatch(); attachPublishToggles(); }); /* ---------- Большая модалка «Выдача вариантов» ---------- */ function refreshAssignModal(url) { var body = document.getElementById('assign-modal-body'); if (!body) return Promise.resolve(); return fetch(url, { headers: { 'X-Requested-With': 'fetch' } }) .then(function (r) { if (r.redirected) throw new Error('сессия истекла — обновите страницу'); return r.text(); }) .then(function (html) { body.innerHTML = html; }); } window.openLabAssign = function (btn) { var body = document.getElementById('assign-modal-body'); if (!body) return; var title = document.getElementById('assign-modal-title'); if (title) title.textContent = 'Выдача вариантов — ' + (btn.getAttribute('data-lab-name') || ''); body.innerHTML = '

Загрузка…

'; openModal('modal-lab-assign'); refreshAssignModal(btn.getAttribute('data-url')).catch(function (e) { body.innerHTML = '

Не удалось загрузить: ' + e.message + '

'; }); }; window.confirmAssignVariant = function (sel) { var opt = sel.selectedOptions[0]; var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0; var submit = function () { sel.dataset.prev = sel.value; sel.closest('form').requestSubmit(); }; if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) { window.showConfirm({ text: 'Вариант уже занят (' + taken + '). Назначить всё равно?', okText: 'Назначить' }).then(function (ok) { if (ok) submit(); else sel.value = sel.dataset.prev; }); return; } submit(); }; /* Формы внутри модалки сохраняются через fetch — модалка не закрывается и обновляется */ document.addEventListener('submit', function (ev) { var form = ev.target; if (!form.classList || !form.classList.contains('js-assign-form')) return; ev.preventDefault(); var btn = form.querySelector('button[type="submit"]'); if (btn) btn.disabled = true; fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-Requested-With': 'fetch' } }) .then(function (r) { if (r.redirected) throw new Error('сессия истекла — обновите страницу'); return r.text(); }) .then(function () { return refreshAssignModal(form.getAttribute('data-refresh')); }) .catch(function (e) { showToast('Не удалось сохранить: ' + e.message, 'error'); }) .finally(function () { if (btn) btn.disabled = false; }); });