414 lines
16 KiB
JavaScript
414 lines
16 KiB
JavaScript
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 btn-sm';
|
||
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 row = document.createElement('div');
|
||
row.className = 'ac-item';
|
||
|
||
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('.modal-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 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();
|
||
}
|
||
|
||
/* ---------- Назначение варианта (вручную или случайно из уровня) ---------- */
|
||
|
||
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.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 || [];
|
||
(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 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();
|
||
});
|