Admin group: single table, defense actions in cells; flash messages as toasts

This commit is contained in:
2026-09-28 16:23:29 +03:00
parent debf157194
commit 82f231c940
15 changed files with 149 additions and 187 deletions

View File

@@ -14,22 +14,18 @@
<button class="btn btn-secondary" type="button"
th:disabled="${#lists.isEmpty(candidates)}"
onclick="openModal('modal-enroll-student')">Добавить существующего</button>
<button class="btn btn-secondary" type="button" onclick="openDefense(this)">Назначить задание</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
<a class="btn btn-secondary" th:href="'/admin/groups/' + ${group.id} + '/submissions.zip'">Скачать все работы</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Логины XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</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>
<div th:if="${resetPassword}" class="alert alert-warning">
Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Результаты по лабораторным</h2>
<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="matrix-table"
<input class="input input-sm table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<label class="label m-0" for="lab-focus">Показать:</label>
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
@@ -39,14 +35,14 @@
</div>
</div>
<p class="text-sm text-muted mb-4">
Значки в ячейке: «№» — вариант, «🛡» — защита, «↓» — сдача (скачать), «✎» — оценка.
Наведите на значок — появится пояснение; клик по «↓» скачивает последнее решение,
клик по «✎» открывает оценку и комментарии. Раздача вариантов — на странице лабораторной.
Ячейка: «№» — вариант, «🛡» — защита (клик — задание и код), «↓» — сдача, «✎» — оценка.
Раздача вариантов — на странице лабораторной.
</p>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<div class="card card-flush mb-6" th:if="${!#lists.isEmpty(labs)}">
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x">
<table class="matrix tablekit" id="matrix-table">
<table class="matrix tablekit" id="group-table">
<thead>
<tr>
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
@@ -67,6 +63,10 @@
</div>
</div>
</th>
<th data-filter>Основа</th>
<th>Варианты</th>
<th data-filter>Пароль</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
@@ -83,11 +83,12 @@
№<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''"
th:text="${cell.variantNumber} ?: '—'">12</span>
</span>
<span th:if="${cell.defenseStatus == null}" class="cell-chip chip-dim" title="Защита не назначена">🛡—</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}" class="cell-chip chip-warn" title="Задание для защиты выдано, ещё без кода">🛡⌛</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}" class="cell-chip chip-warn" title="Защита: код студенту выдан, ждёт входа">🛡🔑</span>
<span th:if="${cell.defenseStatus == 'started'}" class="cell-chip chip-warn" title="Защита: студент пишет работу">🛡⏳</span>
<span th:if="${cell.defenseStatus == 'ready'}" class="cell-chip chip-ok" title="Защита: работа сохранена, можно принимать">🛡✓</span>
<button class="cell-chip" type="button"
th:classappend="${cell.defenseStatus == null} ? 'chip-dim' : (${cell.defenseStatus == 'ready'} ? 'chip-ok' : 'chip-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}"
onclick="openDefense(this)"
th:title="${cell.defenseStatus == null} ? 'Назначить задание на защиту' : 'Защита: задание и действия'"
th:text="${cell.defenseStatus == null} ? '🛡—' : (${cell.defenseStatus == 'waiting'} ? (${cell.defenseCode != null} ? '🛡🔑' : '🛡⌛') : (${cell.defenseStatus == 'started'} ? '🛡⏳' : '🛡✓'))">🛡—</button>
<a th:if="${cell.submissionId}" class="cell-chip chip-link"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}"
title="Скачать последнее решение студента">↓<span class="font-mono" th:text="${cell.submissionSeq}">1</span></a>
@@ -99,114 +100,34 @@
>✎<span class="font-mono" th:if="${cell.score}" th:text="' ' + ${cell.score}"></span></button>
</div>
</td>
<td class="text-muted" th:text="${row.basis}">—</td>
<td class="text-sm">
<span th:if="${!#strings.isEmpty(row.variantsSummary)}" th:text="${row.variantsSummary}">—</span>
<span th:if="${#strings.isEmpty(row.variantsSummary)}" class="text-muted">не распределены</span>
</td>
<td><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}/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>
<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>
</td>
</tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 1}">В группе нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 5}">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
<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)}">
<label class="label m-0" for="defense-lab-filter">Лабораторная:</label>
<select class="input input-sm" id="defense-lab-filter" onchange="filterDefense()">
<option value="">Все</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
<button class="btn btn-primary btn-sm" type="button" onclick="openDefenseForm(this)">Назначить задание</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
<input class="input input-sm table-search" data-target="defense-table"
placeholder="Поиск по студенту…" aria-label="Поиск по заданиям защиты">
</div>
</div>
<div class="card card-flush mb-8">
<table id="defense-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="ФИО — по возрастанию">Студент</th><th data-filter>ЛР</th><th>Задание</th><th data-filter>Статус</th><th class="text-right" data-nosort>Действия</th></tr></thead>
<tbody>
<tr th:each="d : ${defenseRows}" th:attr="data-lab=${d.labId}">
<td>
<div th:text="${d.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${d.studentNo}">—</div>
</td>
<td class="font-mono" th:text="|ЛР ${d.labNumber}|">—</td>
<td class="text-sm text-muted" th:text="${#strings.abbreviate(d.assignmentMd, 60)}">—</td>
<td th:attr="data-filter-value=${d.status == 'waiting' ? (d.code != null ? 'код выдан' : 'ждёт кода') : (d.status == 'started' ? 'пишет' : (d.status == 'ready' ? 'готов' : d.status))}">
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code == null}">ждёт кода</span>
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code != null}">код выдан</span>
<span class="badge badge-warn" th:if="${d.status == 'started'}">пишет (<span th:text="${d.secondsLeft}"></span> c)</span>
<span class="badge badge-ok" th:if="${d.status == 'ready'}">готов</span>
</td>
<td class="text-right defense-cell-actions">
<form class="inline-form" th:if="${d.sessionId != null}"
th:action="'/admin/defenses/' + ${d.sessionId} + '/issue-code'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${d.labId}">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary btn-sm" type="submit"
th:text="${d.status == 'waiting' and d.code != null ? 'новый код' : 'выдать код'}">выдать код</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:attr="data-student=${d.studentId},data-student-name=${d.fullName},data-lab=${d.labId},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
onclick="openDefenseForm(this)">изменить</button>
<button class="btn btn-secondary btn-sm" type="button"
th:attr="data-student-name=${d.fullName},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
onclick="showDefenseText(this)">текст</button>
<form class="inline-form" th:if="${d.status == 'ready'}"
th:action="'/admin/defenses/' + ${d.sessionId} + '/reset'" method="post"
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${d.labId}">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger btn-sm" type="submit">доп. задание: начать заново</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(defenseRows)}"><td colspan="5" class="empty">Заданий для защиты пока нет.</td></tr>
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Студенты</h2>
<input class="input input-sm table-search" data-target="students-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студентам">
</div>
<div class="card card-flush">
<table id="students-table" class="tablekit">
<thead><tr><th data-nosort>№</th><th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th><th>Логин</th><th data-filter>Основа</th><th>Варианты</th><th data-filter>Пароль</th><th class="text-right" data-nosort>Действия</th></tr></thead>
<tbody>
<tr th:each="row, iter : ${studentGrades}">
<td class="text-muted" th:text="${iter.count}">1</td>
<td th:text="${row.fullName}">—</td>
<td class="font-mono" th:text="${row.studentNo}">—</td>
<td class="text-muted" th:text="${row.basis}">—</td>
<td class="text-sm">
<span th:if="${!#strings.isEmpty(row.variantsSummary)}" th:text="${row.variantsSummary}">—</span>
<span th:if="${#strings.isEmpty(row.variantsSummary)}" class="text-muted">не распределены</span>
</td>
<td><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}/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>
<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>
</td>
</tr>
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="7" class="empty">В группе пока нет студентов.</td></tr>
</tbody>
</table>
</div>
<!-- Диалог: оценка + комментарии -->
<dialog id="modal-grade" class="modal">
<div class="modal-header">
@@ -231,7 +152,7 @@
</div>
</dialog>
<!-- Диалог: назначение индивидуального задания -->
<!-- Диалог: индивидуальное задание (защита) -->
<dialog id="modal-defense" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
@@ -256,6 +177,24 @@
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить задание</button>
</form>
<div id="defense-actions" class="mt-3" hidden>
<div class="flex flex-wrap gap-2">
<form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary btn-sm" type="submit">выдать код</button>
</form>
<button class="btn btn-secondary btn-sm" type="button" onclick="showDefenseFromModal()">текст задания</button>
<form class="inline-form" id="defense-reset-form" method="post"
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger btn-sm" type="submit">начать заново</button>
</form>
</div>
</div>
</div>
</dialog>
@@ -359,7 +298,6 @@
</div>
</div>
<script src="/js/admin-group.js" defer></script>
</div>
</body>

View File

@@ -4,8 +4,6 @@
<body>
<div id="content">
<h1 class="page-title">Группы</h1>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/groups}" method="post">
<h2 class="section-title">Создать группу</h2>

View File

@@ -5,8 +5,6 @@
<div id="content">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
<h1 class="page-title mt-4">Настройка лабораторной</h1>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<form class="form-stack" method="post" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">

View File

@@ -29,8 +29,6 @@
<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>

View File

@@ -14,8 +14,6 @@
</div>
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div class="card card-flush">
<table id="labs-table" class="tablekit">

View File

@@ -12,7 +12,6 @@
<p class="text-sm text-muted mb-4" th:if="${currentUser == null or !currentUser.mustChangePassword}">
Смените пароль, если хотите. После сохранения вы останетесь в системе.
</p>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<form class="card form-card form-stack" th:action="@{/change-password}" method="post">
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" required></div>
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div>

View File

@@ -108,5 +108,33 @@
LMS v0.2.0
</footer>
</div>
<div class="toast-host" id="toast-host" aria-live="polite">
<div th:if="${notice}" class="toast-item toast-success" data-toast>
<span class="toast-text" th:text="${notice}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
</div>
<div th:if="${error}" class="toast-item toast-error" data-toast>
<span class="toast-text" th:text="${error}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
</div>
<div th:if="${resetPassword}" class="toast-item toast-warning" data-toast>
<span class="toast-text">Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code></span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-toast]').forEach(function (toast) {
var timer = setTimeout(function () { toast.remove(); }, 30000);
var close = toast.querySelector('.toast-close');
if (close) {
close.addEventListener('click', function () {
clearTimeout(timer);
toast.remove();
});
}
});
});
</script>
</body>
</html>

View File

@@ -20,9 +20,6 @@
<span id="sort-status-text">Изменения не сохранены…</span>
</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>
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
Материалы ещё не добавлены.
</div>

View File

@@ -5,7 +5,6 @@
<div id="content">
<div class="container-sm">
<h1 class="page-title">Вход</h1>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div th:if="${!adminExists}" class="alert alert-warning">
Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a>
</div>

View File

@@ -6,7 +6,6 @@
<div class="container-sm">
<h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<form class="card form-card form-stack" th:action="@{/setup}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required><p class="hint">Минимум 8 символов.</p></div>

View File

@@ -14,9 +14,6 @@
<span th:if="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span>
</p>
<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>