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

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<!-- Фрагмент большой модалки «Выдача вариантов» на странице группы.
Рендерится отдельно (без layout) и подставляется в #assign-modal-body через fetch. -->
<body>
<div th:fragment="content">
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<form class="inline-form js-assign-form" th:action="@{/admin/labs/{labId}/distribute(labId=${lab.id})}" method="post"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupIds" th:value="${groupId}">
<label class="tone-label"><input type="checkbox" name="force" value="true"> пересоздать заново</label>
<button class="btn btn-primary" type="submit">Распределить автоматически</button>
</form>
<span class="text-sm text-muted" th:text="|Занято вариантов: ${#maps.size(variantUsage)}|">—</span>
</div>
<div class="scroll-x">
<table>
<thead><tr>
<th class="th-sortable" title="ФИО — по возрастанию">Студент</th>
<th>Уровень</th><th>Вариант</th><th data-nosort>Источник</th><th data-nosort>Сдача</th>
</tr></thead>
<tbody>
<tr th:each="row : ${rows}">
<td>
<div class="font-medium" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</td>
<td>
<form class="inline-form js-assign-form" method="post"
th:action="@{/admin/labs/{labId}/set-level(labId=${lab.id})}"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input" name="labLevelId" th:disabled="${#lists.isEmpty(lab.levels)}"
onchange="this.closest('form').requestSubmit()">
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
th:selected="${lv.id == row.levelId}" th:text="${lv.title}">Уровень</option>
</select>
</form>
</td>
<td>
<form class="inline-form js-assign-form" method="post"
th:action="@{/admin/labs/{labId}/assign(labId=${lab.id})}"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input" name="variantId" onchange="confirmAssignVariant(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 == row.variantId}"
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
</optgroup>
</select>
</form>
</td>
<td>
<span class="badge" th:classappend="${row.manual} ? 'badge-ok' : ''"
th:text="${row.manual} ? 'вручную' : 'авто'">авто</span>
</td>
<td>
<a th:if="${row.submissionId}" class="sub-link"
th:href="@{/admin/submissions/{id}/download(id=${row.submissionId})}">сдача ↓ <span th:text="${row.submissionSeq}">1</span></a>
<span th:unless="${row.submissionId}" class="sub-dim">не сдано</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(rows)}"><td colspan="5" class="empty">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -19,7 +19,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Лабораторные</h2>
<a th:href="@{/admin/labs}" class="text-sm">Все →</a>
<a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
</div>
<div class="card card-flush">
<table>
@@ -36,7 +36,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все →</a>
<a th:href="@{/admin/groups}" class="text-sm">Все группы →</a>
</div>
<div class="card card-flush">
<table>

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>

View File

@@ -3,31 +3,19 @@
<head><title>Группы — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Группы</h1>
<div class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/groups}" method="post">
<h2 class="section-title">Создать группу</h2>
<div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<form class="card form-card form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт студентов</h2>
<p class="text-sm text-muted">Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения.</p>
<div><label class="label" for="file">Файл со списком</label><input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label><input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 mt-8">
<h2 class="section-title m-0">Список групп</h2>
<input class="input input-sm table-search" data-target="groups-table"
placeholder="Поиск по названию…" aria-label="Поиск по группам">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="page-title m-0">Группы</h1>
<div class="flex gap-2">
<input class="input table-search" data-target="groups-table"
placeholder="Поиск по названию…" aria-label="Поиск по группам">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-group')">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-group')">+ Новая группа</button>
</div>
</div>
<div class="card card-flush">
<table id="groups-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Выгрузка</th></tr></thead>
<thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Пароли</th></tr></thead>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
@@ -38,6 +26,39 @@
</tbody>
</table>
</div>
<dialog id="modal-create-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая группа</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-create-group')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups}" method="post">
<div><label class="label" for="name">Название</label>
<input class="input" id="name" name="name" placeholder="ПИ-11БО" required autofocus></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-import-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт студентов</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import-group')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<p class="text-sm text-muted">Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения. Логин — студенческий номер, пароль сгенерируется и покажется после импорта.</p>
<div><label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label>
<input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</div>
</body>
</html>
</html>

View File

@@ -24,9 +24,9 @@
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="status">Статус</label>
<select class="input" id="status" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft (черновик)</option>
<option value="published" th:selected="${lab.status == 'published'}">published (опубликована)</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
<option value="draft" th:selected="${lab.status == 'draft'}">Черновик</option>
<option value="published" th:selected="${lab.status == 'published'}">Опубликована</option>
<option value="archived" th:selected="${lab.status == 'archived'}">В архиве</option>
</select></div>
</div>
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
@@ -45,9 +45,9 @@
<input class="input" name="levelTitles" th:value="${level.title}" required>
</td>
<td class="text-right">
<button class="btn btn-danger btn-sm" type="submit" name="action"
<button class="btn btn-danger" type="submit" name="action"
th:value="|delete-level-${level.id}|"
onclick="return confirm('Удалить уровень? Варианты на нём должны быть пустыми.')">
onclick="return confirm('Удалить уровень? Несохранённые правки остальных полей формы будут потеряны. Варианты на уровне должны быть пустыми.')">
удалить</button>
</td>
</tr>
@@ -81,9 +81,9 @@
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="variantStatements" rows="6" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-danger btn-sm" type="submit" name="action"
<button class="btn btn-danger" type="submit" name="action"
th:value="|delete-variant-${v.id}|"
onclick="return confirm('Удалить вариант?')">Удалить вариант</button>
onclick="return confirm('Удалить вариант? Несохранённые правки остальных полей формы будут потеряны.')">Удалить вариант</button>
</div>
</div>

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>

View File

@@ -6,34 +6,41 @@
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1>
<div class="flex gap-2">
<input class="input input-sm table-search" data-target="labs-table"
<input class="input table-search" data-target="labs-table"
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
</div>
</div>
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
<div class="card card-flush">
<table id="labs-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort></th></tr></thead>
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td th:text="${lab.variantCount}">0</td>
<td th:attr="data-filter-value=${lab.status}">
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
<td th:attr="data-filter-value=${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">
<span class="badge"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">—</span>
</td>
<td class="text-center">
<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"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
type="submit" th:text="${lab.status}">—</button>
<input type="hidden" name="back" th:value="@{/admin/labs}">
<button class="btn"
th:classappend="${lab.status == 'published'} ? 'btn-secondary' : 'btn-primary'"
type="submit"
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">—</button>
</form>
</td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger btn-sm" type="submit">Удалить</button></form></td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"
onsubmit="return confirm('Удалить лабораторную вместе с вариантами и назначениями? Действие необратимо.')"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger" type="submit">Удалить</button></form></td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
</table>
</div>
@@ -42,7 +49,7 @@
<dialog id="modal-import-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import-lab')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
@@ -65,7 +72,7 @@
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-create-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">

View File

@@ -6,9 +6,9 @@
<h1 class="page-title">Пользователи</h1>
<form class="flex flex-wrap items-center gap-2 mb-4" method="get" th:action="@{/admin/users}">
<input class="input input-sm" name="q" th:value="${query}" placeholder="Поиск по ФИО, логину, номеру или группе…" aria-label="Поиск пользователей">
<button class="btn btn-secondary btn-sm" type="submit">Найти</button>
<a class="btn btn-secondary btn-sm" th:href="@{/admin/users}" th:if="${!#strings.isEmpty(query)}">Сбросить</a>
<input class="input" name="q" th:value="${query}" placeholder="Поиск по ФИО, логину, номеру или группе…" aria-label="Поиск пользователей">
<button class="btn btn-secondary" type="submit">Найти</button>
<a class="btn btn-secondary" th:href="@{/admin/users}" th:if="${!#strings.isEmpty(query)}">Сбросить</a>
<span class="text-sm text-muted">Найдено: <span th:text="${#lists.size(users)}">0</span></span>
</form>
@@ -34,7 +34,7 @@
onsubmit="return confirm('Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.');">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="q" th:value="${query}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</td>
</tr>