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 inp = document.createElement('input'); inp.className = 'input cs-text'; inp.placeholder = 'Комментарий'; inp.value = text || ''; var sel = document.createElement('select'); sel.className = 'input cs-tone'; [['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 btn-sm'; rm.textContent = 'удалить'; rm.onclick = function () { row.remove(); }; row.appendChild(inp); 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 row = document.createElement('div'); row.className = 'ac-item'; row.textContent = item.text; row.addEventListener('mousedown', function (event) { event.preventDefault(); input.value = item.text; var tone = input.parentNode.querySelector('.cs-tone'); if (tone && item.tone) tone.value = item.tone; hide(); }); list.appendChild(row); }); list.hidden = false; active = -1; } 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 defenseStatusText(status, code, seconds) { if (status === 'waiting') { return code ? 'Задание назначено, код выдан — ждём входа студента.' : 'Задание назначено, код ещё не выдан.'; } if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.'; if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.'; return ''; } /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */ 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 actions = $id('defense-actions'); var statusEl = $id('defense-status'); 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); actions.hidden = false; } else { if (statusEl) statusEl.textContent = ''; actions.hidden = true; } var d = $id('modal-defense'); if (d && d.showModal) d.showModal(); } /* ---------- Назначение варианта (вручную или случайно из уровня) ---------- */ 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 = ''; fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema') .then(function (r) { return r.ok ? r.json() : { 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 || []; levelSel.appendChild(opt); }); 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 || ''; }; } /* ---------- Таймеры защиты в ячейках ---------- */ function tickDefenseCells() { document.querySelectorAll('.defense-cell-timer').forEach(function (el) { var left = parseInt(el.getAttribute('data-seconds') || '0', 10); var tick = setInterval(function () { left -= 1; if (left <= 0) { clearInterval(tick); el.textContent = '0:00'; return; } el.textContent = fmtSeconds(left); }, 1000); el.textContent = fmtSeconds(left); }); } /* ---------- Матрица: фокус на лабораторной ---------- */ 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.style.display = (!labId || el.getAttribute('data-lab') === labId) ? '' : 'none'; }); } document.addEventListener('submit', function (ev) { if (ev.target && ev.target.id === 'grade-form') csSerialize(); }, true); window.addEventListener('DOMContentLoaded', function () { focusLab(); tickDefenseCells(); });