Redesign B3 bordeaux + finish lab-page rework

- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
This commit is contained in:
2026-10-02 15:41:16 +03:00
parent c0cf11a5a3
commit 51d1b84297
67 changed files with 3019 additions and 653 deletions

View File

@@ -7,138 +7,31 @@
<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">
<div class="text-sm text-muted">
Статус: <span class="badge"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">draft</span>
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post"
onsubmit="return confirm('Сменить статус лабораторной? Опубликованную увидят студенты, черновик — перестанут.')">
<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>
<button class="btn btn-secondary" type="submit"
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать</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>
<span th:if="${totalVariants > 0}"> · Вариантов занято: <span th:text="${occupiedVariants}"></span> из <span th:text="${totalVariants}"></span></span>
<span th:if="${submissionCount > 0}"> · Сдач: <span th:text="${submissionCount}"></span></span>
</div>
</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>
<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, gStat : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h3 class="group-title m-0" th:text="${g.groupName}">Группа</h3>
<input class="input input-sm table-search" th:attr="data-target='assign-table-' + ${gStat.index}"
placeholder="Поиск по студенту…" aria-label="Поиск по студентам группы">
</div>
<div class="scroll-x">
<table class="tablekit" th:attr="id='assign-table-' + ${gStat.index}">
<thead><tr>
<th class="th-w-sm" data-nosort>Выбор</th><th class="th-sortable is-asc" title="ФИО — по возрастанию">Студент</th><th class="th-w-md" data-filter>Уровень</th><th class="th-w-md">Вариант</th>
<th class="th-w-sm" data-filter>Источник</th><th data-filter>Сдача</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 th:attr="data-filter-value=${s.submissionId != null ? 'сдано' : 'нет'}">
<a th:if="${s.submissionId}" class="cell-link"
th:href="@{/admin/submissions/{id}/download(id=${s.submissionId}, group=${g.groupName}, no=${s.studentNo}, name=${s.fullName}, attempt=${s.submissionSeq})}">
попытка <span th:text="${s.submissionSeq}">1</span> ↓</a>
<div th:if="${!#lists.isEmpty(s.submissionFiles)}" class="text-muted text-sm font-mono">
<span th:text="${#strings.listJoin(s.submissionFiles, ', ')}">main.c</span>
</div>
<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">
@@ -162,40 +55,6 @@
</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>