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 fba4e013e5
commit b94538cb7e
13 changed files with 371 additions and 45 deletions

View File

@@ -68,6 +68,28 @@ class DefenseAdminController(
) )
} }
/** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */
@GetMapping("/watch")
fun watch(
@RequestParam labs: String,
): List<DefenseAdminDto> {
val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() }
return service.listForLabs(ids).map {
DefenseAdminDto(
it.id,
it.studentId,
it.labId,
it.status,
it.code,
it.assignmentMd,
it.startedAt,
it.readyAt,
it.readyReason,
it.secondsLeft,
)
}
}
@PostMapping("/{id}/issue-code") @PostMapping("/{id}/issue-code")
fun issueCode( fun issueCode(
@PathVariable id: UUID, @PathVariable id: UUID,

View File

@@ -30,6 +30,17 @@ class DefenseMyController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideForStudent(it) } ): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideForStudent(it) }
/** Компактный статус для синхронизации таймера раз в ~10 с. */
@GetMapping("/{labId}/status")
fun status(
@PathVariable labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): Map<String, Any?> {
val d = service.mySession(UUID.fromString(jwt.subject), labId)
if (d == null) return mapOf("status" to "none", "secondsLeft" to null, "readyReason" to null)
return mapOf("status" to d.status, "secondsLeft" to d.secondsLeft, "readyReason" to d.readyReason)
}
/** Студент не должен видеть ни код, ни текст задания до ввода кода. */ /** Студент не должен видеть ни код, ни текст задания до ввода кода. */
private fun hideForStudent(d: DefenseDto): DefenseDto = private fun hideForStudent(d: DefenseDto): DefenseDto =
if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d

View File

@@ -108,6 +108,14 @@ class DefenseService(
return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) } return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) }
} }
/** Наблюдение за сессиями сразу по нескольким лабораторным (раз в ~10 с со страницы группы). */
@Transactional
fun listForLabs(labIds: Collection<UUID>): List<DefenseDto> {
if (labIds.isEmpty()) return emptyList()
syncTimeouts()
return sessions.findByLabIdIn(labIds).map { toDto(it) }
}
@Transactional @Transactional
fun startByCode( fun startByCode(
studentId: UUID, studentId: UUID,

View File

@@ -7,6 +7,8 @@ import java.util.UUID
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> { interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession> fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByLabIdIn(labIds: Collection<UUID>): List<DefenseSession>
fun findByStudentIdAndLabId( fun findByStudentIdAndLabId(
studentId: UUID, studentId: UUID,
labId: UUID, labId: UUID,

View File

@@ -2,17 +2,20 @@ package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.assignDefense import ru.ysu.lms.web.api.assignDefense
import ru.ysu.lms.web.api.defenseWatch
import ru.ysu.lms.web.api.issueDefenseCode import ru.ysu.lms.web.api.issueDefenseCode
import ru.ysu.lms.web.api.resetDefense import ru.ysu.lms.web.api.resetDefense
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
@@ -24,6 +27,37 @@ class AdminDefenseController(
markdown: MarkdownRenderer, markdown: MarkdownRenderer,
mapper: ObjectMapper, mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) { ) : AdminSupport(api, markdown, mapper) {
/**
* JSON-наблюдение за активными защитами по labs=id1,id2,...
* Страница группы опрашивает его каждые ~10 секунд: таймеры синхронизируются с сервером.
*/
@GetMapping("/defense-watch")
@ResponseBody
fun defenseWatch(
@RequestParam labs: String,
session: HttpSession,
): Map<String, Any?> {
val t = token(session) ?: return mapOf("ok" to false, "error" to "Нужно войти как преподаватель")
return try {
val ids = labs.split(',').map { it.trim() }.filter { it.isNotEmpty() }
val sessions =
api.defenseWatch(t, ids).map {
mapOf(
"sessionId" to it.id,
"studentId" to it.studentId,
"labId" to it.labId,
"status" to it.status,
"code" to it.code,
"secondsLeft" to it.secondsLeft,
"readyReason" to it.readyReason,
)
}
mapOf("ok" to true, "sessions" to sessions)
} catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message)
}
}
@PostMapping("/labs/{labId}/defense/assign") @PostMapping("/labs/{labId}/defense/assign")
fun assignDefense( fun assignDefense(
@PathVariable labId: String, @PathVariable labId: String,

View File

@@ -204,6 +204,7 @@ class AdminGroupController(
defenseStatus = def?.status, defenseStatus = def?.status,
defenseCode = def?.code, defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft, defenseSecondsLeft = def?.secondsLeft,
defenseReadyReason = def?.readyReason,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true, defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id, defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd, defenseAssignmentMd = def?.assignmentMd,
@@ -403,7 +404,14 @@ class AdminGroupController(
if (published) current.add(studentId) else current.remove(studentId) if (published) current.add(studentId) else current.remove(studentId)
val target = current.filter { it in memberIdSet } val target = current.filter { it in memberIdSet }
api.setPublication(t, PublicationSetPayload(id, labId, target)) api.setPublication(t, PublicationSetPayload(id, labId, target))
mapOf("ok" to true, "published" to published, "count" to target.size) val total = memberIds.size
mapOf(
"ok" to true,
"published" to published,
"count" to target.size,
"total" to total,
"all" to (total > 0 && target.size == total),
)
} catch (ex: ApiException) { } catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message) mapOf("ok" to false, "error" to ex.message)
} }

View File

@@ -58,6 +58,7 @@ data class GroupLabCell(
val defenseStatus: String? = null, val defenseStatus: String? = null,
val defenseCode: String? = null, val defenseCode: String? = null,
val defenseSecondsLeft: Long? = null, val defenseSecondsLeft: Long? = null,
val defenseReadyReason: String? = null,
val defenseAssigned: Boolean = false, val defenseAssigned: Boolean = false,
val defenseSessionId: String? = null, val defenseSessionId: String? = null,
val defenseAssignmentMd: String? = null, val defenseAssignmentMd: String? = null,

View File

@@ -19,6 +19,22 @@ fun ApiClient.labDefenses(
} }
} }
/** Наблюдение за сессиями защиты по списку лабораторных (для периодической синхронизации таймеров). */
fun ApiClient.defenseWatch(
token: String,
labIds: List<String>,
): List<DefenseDto> {
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
return call {
client
.get()
.uri("/api/v1/defenses/watch?labs={labs}", labIds.joinToString(","))
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.assignDefense( fun ApiClient.assignDefense(
token: String, token: String,
studentId: String, studentId: String,
@@ -88,6 +104,20 @@ fun ApiClient.myDefense(
.body(DefenseDto::class.java) .body(DefenseDto::class.java)
} }
/** Компактный статус защиты для синхронизации таймера на странице студента. */
fun ApiClient.myDefenseStatus(
token: String,
labId: String,
): Map<*, *> =
call {
client
.get()
.uri("/api/v1/me/defenses/{labId}/status", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(Map::class.java)
}
fun ApiClient.startDefense( fun ApiClient.startDefense(
token: String, token: String,
code: String, code: String,

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
@@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.meAssignments import ru.ysu.lms.web.api.meAssignments
import ru.ysu.lms.web.api.myDefense import ru.ysu.lms.web.api.myDefense
import ru.ysu.lms.web.api.myDefenseStatus
import ru.ysu.lms.web.api.myGrades import ru.ysu.lms.web.api.myGrades
import ru.ysu.lms.web.api.myGroup import ru.ysu.lms.web.api.myGroup
import ru.ysu.lms.web.api.mySubmissions import ru.ysu.lms.web.api.mySubmissions
@@ -188,6 +190,22 @@ class StudentController(
} }
} }
/** Компактный статус защиты для синхронизации таймера (~раз в 10 с). */
@GetMapping("/labs/{id}/defense-status")
@ResponseBody
fun defenseStatus(
@PathVariable id: String,
session: HttpSession,
): Map<String, Any?> {
val t = token(session) ?: return mapOf("ok" to false)
return try {
val s = api.myDefenseStatus(t, id)
mapOf("ok" to true, "status" to s["status"], "secondsLeft" to s["secondsLeft"], "readyReason" to s["readyReason"])
} catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message)
}
}
@GetMapping("/submissions/{id}/download") @GetMapping("/submissions/{id}/download")
fun download( fun download(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -260,11 +260,12 @@ function attachPublishToggles() {
if (!urlEl) return; if (!urlEl) return;
var csrfEl = document.querySelector('input[name="_csrf"]'); var csrfEl = document.querySelector('input[name="_csrf"]');
document.querySelectorAll('.pub-toggle').forEach(function (cb) { document.querySelectorAll('.pub-toggle').forEach(function (cb) {
var labId = cb.getAttribute('data-lab') || '';
cb.addEventListener('change', function () { cb.addEventListener('change', function () {
var desired = cb.checked; var desired = cb.checked;
var body = new URLSearchParams(); var body = new URLSearchParams();
body.set('_csrf', csrfEl ? csrfEl.value : ''); 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('studentId', cb.getAttribute('data-student') || '');
body.set('published', desired ? 'true' : 'false'); body.set('published', desired ? 'true' : 'false');
cb.disabled = true; cb.disabled = true;
@@ -277,9 +278,14 @@ function attachPublishToggles() {
.then(function (res) { .then(function (res) {
cb.disabled = false; cb.disabled = false;
if (res && res.ok) { 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 { } else {
cb.checked = !desired; cb.checked = !desired;
showToast((res && res.error) || 'Не удалось изменить публикацию.', 'error'); 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') { if (status === 'waiting') {
return code return code
? 'Задание назначено, код выдан — ждём входа студента.' ? 'Задание назначено, код выдан — ждём входа студента.'
: 'Задание назначено, код ещё не выдан.'; : 'Задание назначено, код ещё не выдан.';
} }
if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.'; if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.';
if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.'; if (status === 'ready') {
var why = defenseReasonText(reason);
return 'Студент готов к сдаче' + (why ? ' (' + why + ')' : '') + '. Можно начать заново для дополнительного задания.';
}
return ''; return '';
} }
/** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */ /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
var DEFENSE_OPEN_SESSION = '';
function openDefense(btn) { function openDefense(btn) {
var form = $id('defense-form'); var form = $id('defense-form');
if (!form) return; if (!form) return;
@@ -321,15 +479,17 @@ function openDefense(btn) {
var status = btn.getAttribute('data-status') || ''; var status = btn.getAttribute('data-status') || '';
var code = btn.getAttribute('data-code') || ''; var code = btn.getAttribute('data-code') || '';
var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10); var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10);
var reason = btn.getAttribute('data-reason') || '';
var actions = $id('defense-actions'); var actions = $id('defense-actions');
var statusEl = $id('defense-status'); var statusEl = $id('defense-status');
DEFENSE_OPEN_SESSION = session;
if (session) { if (session) {
$id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code'; $id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code';
$id('defense-reset-form').action = '/admin/defenses/' + session + '/reset'; $id('defense-reset-form').action = '/admin/defenses/' + session + '/reset';
$id('defense-action-lab').value = lab; $id('defense-action-lab').value = lab;
$id('defense-reset-lab').value = lab; $id('defense-reset-lab').value = lab;
$id('defense-reset-form').hidden = status !== 'ready'; $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; actions.hidden = false;
} else { } else {
if (statusEl) statusEl.textContent = ''; 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() { function focusLab() {
@@ -458,6 +604,6 @@ document.addEventListener('submit', function (ev) {
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
focusLab(); focusLab();
tickDefenseCells(); startDefenseWatch();
attachPublishToggles(); attachPublishToggles();
}); });

View File

@@ -1,15 +1,59 @@
/* Таймер защиты: локальный отщёлник + подстройка по серверу каждые ~10 секунд. */
document.querySelectorAll('.defense-timer').forEach(function (el) { document.querySelectorAll('.defense-timer').forEach(function (el) {
var labId = el.getAttribute('data-lab') || '';
var left = parseInt(el.getAttribute('data-seconds') || '0', 10); var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = null;
var ended = false;
function render(v) { function render(v) {
var m = Math.floor(v / 60), s = v % 60; var m = Math.floor(v / 60), s = v % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s; el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
} }
render(left);
var tick = setInterval(function () { function startTick() {
left -= 1; if (tick) clearInterval(tick);
if (left <= 0) { clearInterval(tick); window.location.reload(); return; } 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); 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) { document.querySelectorAll('.ru-datetime').forEach(function (el) {
try { try {

View File

@@ -30,6 +30,7 @@
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div> <div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}"> <input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x"> <div class="scroll-x">
<table class="matrix tablekit" id="group-table"> <table class="matrix tablekit" id="group-table">
@@ -40,18 +41,19 @@
<div class="lab-col-head"> <div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a> <a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<div class="lab-head-actions"> <div class="lab-head-actions">
<form class="inline-form" th:with="pub=${labPublication[lab.id]}" <form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post"> th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input type="hidden" name="published" <input class="js-pub-published" type="hidden" name="published"
th:value="${pub != null and pub.allPublished} ? 'false' : 'true'"> th:value="${all} ? 'false' : 'true'">
<button class="mini-btn" type="submit" <button class="mini-btn js-pub-eye" type="submit"
th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')" th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))"> th:classappend="${all} ? 'ok' : (${pc > 0} ? 'partial' : 'warn')"
<svg th:if="${pub != null and pub.publishedCount > 0}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg> th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<svg th:unless="${pub != null and pub.publishedCount > 0}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg> <svg class="eye-open" th:style="${pc > 0} ? '' : 'display:none'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="mini-count" th:if="${pub != null and !pub.allPublished and pub.publishedCount > 0}" th:text="${pub.publishedCount}"></span> <svg class="eye-closed" th:style="${pc > 0} ? 'display:none' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg>
<span class="mini-count js-pub-count" th:style="(${all} or ${pc == 0}) ? 'display:none' : ''" th:text="${pc}"></span>
</button> </button>
</form> </form>
<a class="mini-btn" <a class="mini-btn"
@@ -88,17 +90,17 @@
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение" <a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓<span class="cell-num" th:text="${cell.submissionSeq}"></span></a> th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓<span class="cell-num" th:text="${cell.submissionSeq}"></span></a>
<span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span> <span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span>
<button class="cell-btn" type="button" <button class="cell-btn js-defense-cell" type="button"
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')" th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode}" th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode},data-reason=${cell.defenseReadyReason}"
onclick="openDefense(this)" onclick="openDefense(this)"
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'"> th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'">
<span>🛡</span> <span>🛡</span>
<span th:if="${cell.defenseStatus == null}">нет</span> <span class="js-def-label"
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}">ожид.</span> th:text="${cell.defenseStatus == null ? 'нет' : (cell.defenseStatus == 'waiting'
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}">код</span> ? (cell.defenseCode != null ? 'код' : 'ожид.')
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer" th:attr="data-seconds=${cell.defenseSecondsLeft}">идёт</span> : (cell.defenseStatus == 'ready' ? 'готов' : ''))}">·</span>
<span th:if="${cell.defenseStatus == 'ready'}">готов</span> <span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer js-def-timer" th:data-seconds="${cell.defenseSecondsLeft}">идёт</span>
</button> </button>
<button class="cell-btn" type="button" <button class="cell-btn" type="button"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}" th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"

View File

@@ -94,7 +94,7 @@
</form> </form>
</th:block> </th:block>
<div th:if="${defense != null and defense.status == 'started'}"> <div th:if="${defense != null and defense.status == 'started'}">
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}"></p> <p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3> <h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div> <div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post"> <form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post">