Defense: sync admin/student timers from server every 10s; toasts with ready reason; live eye counter

This commit is contained in:
2026-10-02 01:21:32 +03:00
parent 95a306c712
commit f7a0dc9d3b
13 changed files with 371 additions and 45 deletions

View File

@@ -260,11 +260,12 @@ function attachPublishToggles() {
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', cb.getAttribute('data-lab') || '');
body.set('labId', labId);
body.set('studentId', cb.getAttribute('data-student') || '');
body.set('published', desired ? 'true' : 'false');
cb.disabled = true;
@@ -277,9 +278,14 @@ function attachPublishToggles() {
.then(function (res) {
cb.disabled = false;
if (res && res.ok) {
showToast(desired
? 'Оценка и комментарии опубликованы студенту.'
: 'Оценка и комментарии скрыты от студента.');
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');
@@ -294,19 +300,171 @@ function attachPublishToggles() {
});
}
/* Перерисовка «глазика» публикации по атрибутам 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.remove('ok', 'partial', 'warn');
eye.classList.add(all ? 'ok' : (count > 0 ? 'partial' : 'warn'));
var on = eye.querySelector('.eye-open');
var off = eye.querySelector('.eye-closed');
if (on) on.style.display = count > 0 ? '' : 'none';
if (off) off.style.display = count > 0 ? 'none' : '';
var c = eye.querySelector('.js-pub-count');
if (c) {
c.textContent = count;
c.style.display = (count > 0 && !all) ? '' : 'none';
}
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 ? 'код' : 'ожид.')));
}
if (s.status === 'ready') {
btn.title = 'Защита завершена: ' + (s.readyReason === 'timeout' ? 'время вышло' : 'студент нажал «Готов»');
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 defenseStatusText(status, code, seconds) {
function defenseReasonText(reason) {
if (reason === 'timeout') return 'время вышло';
if (reason === 'button') return 'студент нажал «Готов»';
return '';
}
function defenseStatusText(status, code, seconds, reason) {
if (status === 'waiting') {
return code
? 'Задание назначено, код выдан — ждём входа студента.'
: 'Задание назначено, код ещё не выдан.';
}
if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.';
if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.';
if (status === 'ready') {
var why = defenseReasonText(reason);
return 'Студент готов к сдаче' + (why ? ' (' + why + ')' : '') + '. Можно начать заново для дополнительного задания.';
}
return '';
}
/** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
var DEFENSE_OPEN_SESSION = '';
function openDefense(btn) {
var form = $id('defense-form');
if (!form) return;
@@ -321,15 +479,17 @@ function openDefense(btn) {
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);
if (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds, reason);
actions.hidden = false;
} else {
if (statusEl) statusEl.textContent = '';
@@ -427,20 +587,6 @@ function showVariantTask() {
}
}
/* ---------- Таймеры защиты в ячейках ---------- */
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() {
@@ -458,6 +604,6 @@ document.addEventListener('submit', function (ev) {
window.addEventListener('DOMContentLoaded', function () {
focusLab();
tickDefenseCells();
startDefenseWatch();
attachPublishToggles();
});

View File

@@ -1,15 +1,59 @@
/* Таймер защиты: локальный отщёлник + подстройка по серверу каждые ~10 секунд. */
document.querySelectorAll('.defense-timer').forEach(function (el) {
var labId = el.getAttribute('data-lab') || '';
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = null;
var ended = false;
function render(v) {
var m = Math.floor(v / 60), s = v % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
}
render(left);
var tick = setInterval(function () {
left -= 1;
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
function startTick() {
if (tick) clearInterval(tick);
tick = setInterval(function () {
left -= 1;
if (left <= 0) { finish(); return; }
render(left);
}, 1000);
}
function finish() {
if (ended) return;
ended = true;
if (tick) clearInterval(tick);
window.location.reload();
}
if (left > 0) {
render(left);
}, 1000);
startTick();
}
if (labId) {
setInterval(function () {
fetch('/student/labs/' + encodeURIComponent(labId) + '/defense-status')
.then(function (r) { return r.ok ? r.json() : { ok: false }; })
.then(function (res) {
if (!res || !res.ok) return;
// У сессии закончилось время.
if (res.status !== 'started') {
finish();
return;
}
var server = parseInt(res.secondsLeft || '0', 10);
if (server <= 0) { finish(); return; }
// Подстраиваем локальный отсчёт под серверное время.
if (Math.abs(server - left) > 2) {
left = server;
startTick();
}
render(left);
})
.catch(function () {});
}, 10000);
}
});
document.querySelectorAll('.ru-datetime').forEach(function (el) {
try {