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

@@ -9,8 +9,10 @@
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
</div>
<div class="flex flex-wrap gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/submissions.zip(id=${group.id})}"
title="ZIP со всеми последними решениями группы по всем лабораторным">Скачать работы</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт заданий</button>
</div>
@@ -18,14 +20,14 @@
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Студенты и лабораторные</h2>
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
<input class="input input-sm table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2" th:if="${!#lists.isEmpty(labs)}">
<input class="input table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input" id="lab-focus" onchange="focusLab()">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
@@ -37,10 +39,17 @@
<thead>
<tr>
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
<th th:each="lab : ${labs}" class="text-center lab-col" data-filter th:attr="data-lab=${lab.id}">
<th th:each="lab : ${labs}" class="text-center lab-col" th:attr="data-lab=${lab.id}">
<div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<button class="mini-btn" type="button"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|"
onclick="openLabAssign(this)"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<svg class="cell-ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<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}">
@@ -80,54 +89,60 @@
th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}"
onclick="openVariant(this)"
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button>
title="Назначить вариант вручную или выдать случайный">
<span th:if="${cell.variantNumber}" th:text="'В-' + ${cell.variantNumber}">В-1</span>
<span th:if="${cell.variantNumber == null}" class="cell-none">выдать</span>
</button>
<button class="score-link" type="button"
th:classappend="${cell.score == null} ? 'none' : ''"
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}"
onclick="openGradeEditor(this)"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Оценки нет — выставить балл и комментарии'">
<span th:text="${cell.score} ?: '—'">—</span>
</button>
<input type="checkbox" class="pub-toggle"
th:checked="${cell.published}"
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
</div>
<div class="cell-btns">
<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 js-defense-cell" type="button"
<div class="cell-sub">
<a th:if="${cell.submissionId}" class="sub-link"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">сдача ↓ <span th:text="${cell.submissionSeq}">1</span></a>
<span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
<button class="cell-btn st 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},data-reason=${cell.defenseReadyReason}"
onclick="openDefense(this)"
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'">
<span>🛡</span>
th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
<span class="cell-dim">защита:</span>
<span class="js-def-label"
th:text="${cell.defenseStatus == null ? 'нет' : (cell.defenseStatus == 'waiting'
? (cell.defenseCode != null ? 'код' : 'ожид.')
: (cell.defenseStatus == 'ready' ? 'готов' : ''))}">·</span>
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}"
onclick="openGradeEditor(this)"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Выставить балл и комментарии'">✎<span class="font-mono" th:if="${cell.score}" th:text="${cell.score}"></span></button>
</div>
</div>
</td>
<td class="text-center">
<div class="cell-stack">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form>
</div>
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
</td>
<td class="text-right actions-cell">
<form class="inline-form"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Отчислить</button>
</form>
<div class="actions-col">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post"
onsubmit="return confirm('Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger" type="submit">Отчислить</button>
</form>
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
@@ -140,7 +155,7 @@
<dialog id="modal-grade" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-grade')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
@@ -153,7 +168,7 @@
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<div id="cs-rows"></div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-secondary" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
@@ -165,7 +180,7 @@
<dialog id="modal-variant" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title">Назначить вариант</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-variant')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-variant')">✕</button>
</div>
<div class="modal-body form-stack">
<p class="text-sm text-muted" id="variant-who">—</p>
@@ -201,7 +216,7 @@
<dialog id="modal-defense" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-defense')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
@@ -249,7 +264,7 @@
<dialog id="modal-defense-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт заданий</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-import')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-defense-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/defenses/import}" method="post" enctype="multipart/form-data">
@@ -277,7 +292,7 @@
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-add-student')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="enroll-form" method="post"
@@ -326,7 +341,42 @@
</div>
</div>
<script src="/js/admin-group.js" defer></script>
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
<div th:if="${importResult}" class="card import-card toast">
<div class="card-body">
<h2 class="section-title">
Группа <span th:text="${importResult.groupName}">—</span> импортирована
</h2>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.added)}">
Новых студентов: <b th:text="${#lists.size(importResult.added)}">0</b> — логины и одноразовые пароли:
</p>
<table th:if="${!#lists.isEmpty(importResult.added)}">
<thead><tr><th>Студент</th><th>Логин</th><th>Пароль</th></tr></thead>
<tbody>
<tr th:each="c : ${importResult.added}">
<td th:text="${c.fullName}">—</td>
<td class="font-mono" th:text="${c.studentNo}">—</td>
<td class="font-mono font-semibold" th:text="${c.password}">—</td>
</tr>
</tbody>
</table>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</p>
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
</div>
</div>
<!-- Большая модалка «Выдача вариантов»: содержимое подгружается фрагментом по лабе -->
<dialog id="modal-lab-assign" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="assign-modal-title">Выдача вариантов</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-lab-assign')">✕</button>
</div>
<div class="modal-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='9')}" defer></script>
</div>
</body>
</html>