671 lines
28 KiB
JavaScript
671 lines
28 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';
|
||
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 = '<p class="text-muted">Загрузка…</p>';
|
||
openModal('modal-lab-assign');
|
||
refreshAssignModal(btn.getAttribute('data-url')).catch(function (e) {
|
||
body.innerHTML = '<p class="text-danger">Не удалось загрузить: ' + e.message + '</p>';
|
||
});
|
||
};
|
||
|
||
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; });
|
||
});
|