Phase 3: individual defense assignments with invite codes
- progress V5 defense_sessions: per student+lab individual task (md), status machine waiting -> started -> ready, one-time 6-digit codes - 30-min timer with server-side lazy expiry (syncTimeouts: started->ready by timeout), countdown JS on student page - admin: assign task dialog (+prefill existing via data-md), CSV import (studentNo;task, \n for newlines) resolved via identity students, issue/reset code buttons, defense status column - student: code entry form (code hidden until start), assignment md, 'Готов к сдаче', waiting-for-teacher note - gateway: /api/v1/defenses,/api/v1/defenses/** routes - web api client + admin/student controllers wired
This commit is contained in:
@@ -98,7 +98,7 @@
|
||||
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||||
<div class="card card-flush mb-6">
|
||||
<table>
|
||||
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th></tr></thead>
|
||||
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th><th>Защита</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${g.students}">
|
||||
<td th:text="${s.fullName}">—</td>
|
||||
@@ -134,14 +134,63 @@
|
||||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
|
||||
th:text="|попытка ${s.submissionSeq}|">↓</a>
|
||||
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
|
||||
<td>
|
||||
<span th:if="${s.defenseStatus == null}" class="text-muted">не назначено</span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode != null}" class="badge badge-warn">код <span class="font-mono" th:text="${s.defenseCode}"></span></span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode == null}" class="badge badge-warn">ждёт задания</span>
|
||||
<span th:if="${s.defenseStatus == 'started'}" class="badge badge-warn">на защите (<span th:text="${s.defenseSecondsLeft}"></span>с)</span>
|
||||
<span th:if="${s.defenseStatus == 'ready'}" class="badge badge-ok">готов к защите</span>
|
||||
<form class="inline-form" th:if="${s.defenseSessionId != null}" th:action="'/admin/defenses/' + ${s.defenseSessionId} + '/issue-code'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus != 'ready'}">выдать код</button>
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus == 'ready'}">заново</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="7" class="empty">В группе нет студентов.</td></tr>
|
||||
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="8" class="empty">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 class="section-title mt-8">Индивидуальные задания для защиты</h2>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-defense-assign')">Выдать / изменить задание</button>
|
||||
<form class="inline-form" th:action="'/admin/labs/' + ${lab.id} + '/defense/import'" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm" type="file" name="file" accept=".csv" required>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Импорт CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.</p>
|
||||
|
||||
<dialog id="modal-defense-assign" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Индивидуальное задание</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-assign')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="'/admin/labs/' + ${lab.id} + '/defense/assign'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<div><label class="label">Студент</label>
|
||||
<select class="input" id="defense-student" name="studentId" required onchange="fillAssignment(this)">
|
||||
<option value="">— выберите студента —</option>
|
||||
<optgroup th:each="g : ${groups}" th:label="${g.groupName}">
|
||||
<option th:each="s : ${g.students}" th:value="${s.studentId}"
|
||||
th:attr="data-md=${s.adminAssignmentMd}"
|
||||
th:text="${s.fullName}">—</option>
|
||||
</optgroup>
|
||||
</select></div>
|
||||
<div><label class="label">Задание (markdown)</label>
|
||||
<textarea class="input" id="defense-md" name="assignmentMd" rows="8" required></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="modal-meta" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка лабораторной</h2>
|
||||
@@ -255,6 +304,7 @@
|
||||
</th:block>
|
||||
|
||||
<script>
|
||||
function fillAssignment(sel) { document.getElementById("defense-md").value = sel.options[sel.selectedIndex].getAttribute("data-md") || ""; }
|
||||
function filterGroups(name) {
|
||||
document.querySelectorAll('.group-section').forEach(function (el) {
|
||||
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
||||
|
||||
@@ -28,7 +28,50 @@
|
||||
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
|
||||
<h2 class="section-title">Защита лабораторной</h2>
|
||||
<div class="card card-body form-stack">
|
||||
<div th:if="${defense == null}" class="text-muted">
|
||||
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
|
||||
</div>
|
||||
<th:block th:if="${defense != null and defense.status == 'waiting'}">
|
||||
<p class="text-sm">Подойдите к преподавателю: он выдаст вам одноразовый код для начала защиты.</p>
|
||||
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<input class="input input-sm" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}" required>
|
||||
<button class="btn btn-primary" type="submit">Приступить к защите</button>
|
||||
</form>
|
||||
</th:block>
|
||||
<div th:if="${defense != null and defense.status == 'started'}">
|
||||
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}" data-labid="${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">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.defense-timer').forEach(function (el) {
|
||||
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
|
||||
var tick = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}, 1000);
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
});
|
||||
</script>
|
||||
<div th:if="${defense != null and defense.status == 'ready'}">
|
||||
<p class="badge badge-ok defense-note">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
|
||||
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
<div class="card form-card form-stack mb-4">
|
||||
<form th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
@@ -56,6 +99,5 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</div></body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user