diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt index e995218..4df0c57 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt @@ -68,6 +68,28 @@ class DefenseAdminController( ) } + /** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */ + @GetMapping("/watch") + fun watch( + @RequestParam labs: String, + ): List { + 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") fun issueCode( @PathVariable id: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt index 2081d51..238aeee 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseMyController.kt @@ -30,6 +30,17 @@ class DefenseMyController( @AuthenticationPrincipal jwt: Jwt, ): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideForStudent(it) } + /** Компактный статус для синхронизации таймера раз в ~10 с. */ + @GetMapping("/{labId}/status") + fun status( + @PathVariable labId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): Map { + 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 = if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt index 687536e..94866cd 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt @@ -108,6 +108,14 @@ class DefenseService( return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) } } + /** Наблюдение за сессиями сразу по нескольким лабораторным (раз в ~10 с со страницы группы). */ + @Transactional + fun listForLabs(labIds: Collection): List { + if (labIds.isEmpty()) return emptyList() + syncTimeouts() + return sessions.findByLabIdIn(labIds).map { toDto(it) } + } + @Transactional fun startByCode( studentId: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt index 3297510..754e42e 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/DefenseSessionRepository.kt @@ -7,6 +7,8 @@ import java.util.UUID interface DefenseSessionRepository : JpaRepository { fun findByLabIdOrderByStudentIdAsc(labId: UUID): List + fun findByLabIdIn(labIds: Collection): List + fun findByStudentIdAndLabId( studentId: UUID, labId: UUID, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt index d829877..1b10eb1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminDefenseController.kt @@ -2,17 +2,20 @@ package ru.ysu.lms.web.admin import jakarta.servlet.http.HttpSession 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.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.multipart.MultipartFile import org.springframework.web.servlet.mvc.support.RedirectAttributes import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException 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.resetDefense import tools.jackson.databind.ObjectMapper @@ -24,6 +27,37 @@ class AdminDefenseController( markdown: MarkdownRenderer, mapper: ObjectMapper, ) : AdminSupport(api, markdown, mapper) { + /** + * JSON-наблюдение за активными защитами по labs=id1,id2,... + * Страница группы опрашивает его каждые ~10 секунд: таймеры синхронизируются с сервером. + */ + @GetMapping("/defense-watch") + @ResponseBody + fun defenseWatch( + @RequestParam labs: String, + session: HttpSession, + ): Map { + 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") fun assignDefense( @PathVariable labId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index e3ecad4..6d5241a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -204,6 +204,7 @@ class AdminGroupController( defenseStatus = def?.status, defenseCode = def?.code, defenseSecondsLeft = def?.secondsLeft, + defenseReadyReason = def?.readyReason, defenseAssigned = def?.assignmentMd?.isNotBlank() == true, defenseSessionId = def?.id, defenseAssignmentMd = def?.assignmentMd, @@ -403,7 +404,14 @@ class AdminGroupController( if (published) current.add(studentId) else current.remove(studentId) val target = current.filter { it in memberIdSet } 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) { mapOf("ok" to false, "error" to ex.message) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 1708ab6..916a43a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -58,6 +58,7 @@ data class GroupLabCell( val defenseStatus: String? = null, val defenseCode: String? = null, val defenseSecondsLeft: Long? = null, + val defenseReadyReason: String? = null, val defenseAssigned: Boolean = false, val defenseSessionId: String? = null, val defenseAssignmentMd: String? = null, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientDefense.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientDefense.kt index 8368ca0..379586b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientDefense.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientDefense.kt @@ -19,6 +19,22 @@ fun ApiClient.labDefenses( } } +/** Наблюдение за сессиями защиты по списку лабораторных (для периодической синхронизации таймеров). */ +fun ApiClient.defenseWatch( + token: String, + labIds: List, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/defenses/watch?labs={labs}", labIds.joinToString(",")) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + fun ApiClient.assignDefense( token: String, studentId: String, @@ -88,6 +104,20 @@ fun ApiClient.myDefense( .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( token: String, code: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index 2819375..08c0153 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.multipart.MultipartFile import org.springframework.web.servlet.mvc.support.RedirectAttributes 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.meAssignments 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.myGroup 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 { + 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") fun download( @PathVariable id: String, diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 3b7c47c..9cab501 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -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(); }); diff --git a/web/src/main/resources/static/js/defense-timer.js b/web/src/main/resources/static/js/defense-timer.js index 1201d84..c26a6cb 100644 --- a/web/src/main/resources/static/js/defense-timer.js +++ b/web/src/main/resources/static/js/defense-timer.js @@ -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 { diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 04766c0..1c81406 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -30,6 +30,7 @@
Опубликованных лабораторных пока нет.
+
@@ -40,18 +41,19 @@
ЛР
-
- - ↓ ↓ -