Defense: sync admin/student timers from server every 10s; toasts with ready reason; live eye counter
This commit is contained in:
@@ -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<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")
|
||||
fun assignDefense(
|
||||
@PathVariable labId: String,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
|
||||
@@ -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<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")
|
||||
fun download(
|
||||
@PathVariable id: String,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<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})}">
|
||||
<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="scroll-x">
|
||||
<table class="matrix tablekit" id="group-table">
|
||||
@@ -40,18 +41,19 @@
|
||||
<div class="lab-col-head">
|
||||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
|
||||
<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">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<input type="hidden" name="published"
|
||||
th:value="${pub != null and pub.allPublished} ? 'false' : 'true'">
|
||||
<button class="mini-btn" type="submit"
|
||||
th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')"
|
||||
th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
|
||||
<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>
|
||||
<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>
|
||||
<span class="mini-count" th:if="${pub != null and !pub.allPublished and pub.publishedCount > 0}" th:text="${pub.publishedCount}"></span>
|
||||
<input class="js-pub-published" type="hidden" name="published"
|
||||
th:value="${all} ? 'false' : 'true'">
|
||||
<button class="mini-btn js-pub-eye" type="submit"
|
||||
th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
|
||||
th:classappend="${all} ? 'ok' : (${pc > 0} ? 'partial' : 'warn')"
|
||||
th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
<a class="mini-btn"
|
||||
@@ -88,17 +90,17 @@
|
||||
<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>
|
||||
<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: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)"
|
||||
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'">
|
||||
<span>🛡</span>
|
||||
<span th:if="${cell.defenseStatus == null}">нет</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}">ожид.</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}">код</span>
|
||||
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer" th:attr="data-seconds=${cell.defenseSecondsLeft}">идёт</span>
|
||||
<span th:if="${cell.defenseStatus == 'ready'}">готов</span>
|
||||
<span class="js-def-label"
|
||||
th:text="${cell.defenseStatus == null ? 'нет' : (cell.defenseStatus == 'waiting'
|
||||
? (cell.defenseCode != null ? 'код' : 'ожид.')
|
||||
: (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 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}"
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</form>
|
||||
</th:block>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user