Files
LMS/web/src/main/resources/static/js/admin-group.js

671 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; });
});