Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers
Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
selected students (checkboxes)
Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
from group page; temporary-password lock (no navigation until changed) and
persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
chapters tree+assets); duplicate lab numbers rejected; variant ordering
and date defaults honored
Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
197 lines
13 KiB
HTML
197 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||
<head><title th:text="${lab.title} + ' — LMS'">Лабораторная</title></head>
|
||
<body>
|
||
<div id="content">
|
||
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
|
||
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6">
|
||
<div>
|
||
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
||
<p class="text-sm text-muted">
|
||
Статус:
|
||
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
|
||
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
|
||
th:text="${lab.status}">draft</button>
|
||
</form>
|
||
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
||
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
||
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
||
<span th:if="${assignedCount > 0}" class="chip chip-neu">Вариантов назначено: <span th:text="${assignedCount}"></span></span>
|
||
<span th:if="${totalVariants > 0}" class="chip chip-neu">Занято вариантов: <span th:text="${occupiedVariants}"></span> из <span th:text="${totalVariants}"></span></span>
|
||
<span th:if="${totalVariants > 0}" class="chip chip-pos">Свободно: <span th:text="${freeVariants}"></span></span>
|
||
<span th:if="${submissionCount > 0}" class="chip chip-pos">Сдано: <span th:text="${submissionCount}"></span></span>
|
||
</p>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
|
||
<button class="btn btn-primary" type="button" onclick="openModal('modal-distribute')">Распределить</button>
|
||
</div>
|
||
</div>
|
||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||
|
||
<h2 class="section-title">Условие лабораторной</h2>
|
||
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
|
||
|
||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||
<h2 class="section-title m-0">Назначения студентов</h2>
|
||
<p class="text-sm text-muted m-0">
|
||
Смена уровня сразу подбирает вариант; конкретный вариант можно закрепить вручную.
|
||
</p>
|
||
</div>
|
||
<div th:if="${#lists.isEmpty(assignmentGroups)}" class="card card-body mb-6 text-muted">Нет студентов для назначения.</div>
|
||
|
||
<div class="card card-body mb-4" th:if="${!#lists.isEmpty(assignmentGroups)}">
|
||
<div class="flex flex-wrap items-center gap-4">
|
||
<label class="tone-label"><input type="checkbox" id="batch-all"
|
||
onchange="toggleAllStudents(this)"> Выбрать всех</label>
|
||
<form id="batch-level-form" class="inline-form" method="post"
|
||
th:action="@{/admin/labs/{id}/batch-level(id=${lab.id})}"
|
||
onsubmit="return collectPicked(this)">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<select name="labLevelId" class="input input-sm" th:disabled="${#lists.isEmpty(lab.levels)}">
|
||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}" th:text="${lv.title}">Уровень</option>
|
||
</select>
|
||
<button class="btn btn-secondary btn-sm" type="submit">Назначить уровень выбранным</button>
|
||
</form>
|
||
<form id="batch-variant-form" class="inline-form" method="post"
|
||
th:action="@{/admin/labs/{id}/batch-variant(id=${lab.id})}"
|
||
onsubmit="return collectPicked(this)">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<select name="variantId" class="input input-sm">
|
||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||
</optgroup>
|
||
</select>
|
||
<button class="btn btn-secondary btn-sm" type="submit">Закрепить вариант выбранным</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div th:each="g : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
|
||
<div class="card-body">
|
||
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||
<div class="scroll-x">
|
||
<table>
|
||
<thead><tr>
|
||
<th class="th-w-sm">Выбор</th><th>Студент</th><th class="th-w-md">Уровень</th><th class="th-w-md">Вариант</th>
|
||
<th class="th-w-sm">Источник</th><th>Сдача</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr th:each="s : ${g.students}">
|
||
<td><input type="checkbox" class="pick-student" th:value="${s.studentId}"></td>
|
||
<td>
|
||
<div th:text="${s.fullName}">—</div>
|
||
<div class="text-sm text-muted font-mono" th:text="${s.studentNo}">—</div>
|
||
</td>
|
||
<td>
|
||
<form class="inline-form" method="post"
|
||
th:action="@{/admin/labs/{id}/set-level(id=${lab.id})}">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||
<select name="labLevelId" class="input input-sm" onchange="this.form.submit()"
|
||
th:disabled="${#lists.isEmpty(lab.levels)}">
|
||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
|
||
th:selected="${lv.id == s.levelId}" th:text="${lv.title}">Уровень</option>
|
||
</select>
|
||
</form>
|
||
</td>
|
||
<td>
|
||
<form class="inline-form" method="post"
|
||
th:action="@{/admin/labs/{id}/assign(id=${lab.id})}">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||
<select name="variantId" class="input input-sm pick-variant" onchange="confirmOccupied(this)">
|
||
<option value="">— авто —</option>
|
||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||
th:attr="data-taken=${variantUsage[v.id] ?: 0}"
|
||
th:selected="${v.id == s.variantId}"
|
||
th:text="${(variantUsage[v.id] ?: 0) > 1 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||
</optgroup>
|
||
</select>
|
||
</form>
|
||
</td>
|
||
<td>
|
||
<span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-ok' : 'badge-warn'"
|
||
th:text="${s.source}">авто</span>
|
||
</td>
|
||
<td>
|
||
<a th:if="${s.submissionId}" class="cell-link"
|
||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'">
|
||
попытка <span th:text="${s.submissionSeq}">1</span> ↓</a>
|
||
<span th:unless="${s.submissionId}" class="text-muted">—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 class="section-title">Уровни и варианты</h2>
|
||
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Уровней пока нет — добавьте на экране настройки.</div>
|
||
<div th:each="level : ${lab.levels}" class="card mb-4">
|
||
<div class="card-body">
|
||
<div class="flex flex-wrap items-center justify-between gap-4 mb-2">
|
||
<h3 class="section-title" th:text="${level.title}">Уровень</h3>
|
||
<span class="text-sm text-muted" th:text="|Вариантов: ${#lists.size(level.variants)}|">0</span>
|
||
</div>
|
||
<div class="scroll-x">
|
||
<table>
|
||
<thead><tr><th class="th-w-sm">№</th><th class="th-w-md">Название</th><th>Условие</th></tr></thead>
|
||
<tbody>
|
||
<tr th:each="v : ${level.variants}">
|
||
<td class="font-mono" th:text="${v.number}">1</td>
|
||
<td th:text="${v.title}">—</td>
|
||
<td class="text-muted preview" th:text="${#strings.abbreviate(v.statementMd, 70)}">—</td>
|
||
</tr>
|
||
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="3" class="empty">Вариантов нет.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog id="modal-distribute" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Распределение вариантов</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-distribute')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<p class="text-sm">
|
||
Студенты выбранных групп раздаются одной общей очерёдностью. Каждый получает
|
||
вариант <b>со своего уровня</b> (без уровня — с первого уровня лабораторной):
|
||
пока внутри уровня есть не выданные никому варианты — они выдаются без повторов,
|
||
затем вариант берётся из наименее занятых. Ручные закрепления сохраняются всегда.
|
||
</p>
|
||
<div th:if="${!#lists.isEmpty(groups)}">
|
||
<label class="label">Группы (ничего не выбрано — все группы)</label>
|
||
<div class="check-list">
|
||
<label class="tone-label" th:each="g : ${groups}">
|
||
<input type="checkbox" name="groupIds" th:value="${g.id}">
|
||
<span th:text="${g.name}">—</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<label class="tone-label">
|
||
<input type="checkbox" name="force" value="true">
|
||
Пересоздать заново (сбросить авто-назначения у выбранных; вручную закреплённые останутся)
|
||
</label>
|
||
<button class="btn btn-primary" type="submit">Распределить</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<script src="/js/admin-lab.js" defer></script>
|
||
</div>
|
||
</body>
|
||
</html>
|