Defense: sync admin/student timers from server every 10s; toasts with ready reason; live eye counter
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user