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 d3f70b0a25
commit c28fd373d6
15 changed files with 149 additions and 187 deletions

View File

@@ -163,6 +163,8 @@ class AdminGroupController(
defenseCode = def?.code, defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft, defenseSecondsLeft = def?.secondsLeft,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true, defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd,
) )
} }
GroupStudentRow( GroupStudentRow(
@@ -185,11 +187,8 @@ class AdminGroupController(
) )
} }
val defenseRows =
buildDefenseRows(t, group, labs)
val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList()) val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList())
fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, defenseRows, publishedLabs)) fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, publishedLabs))
return "admin/group" return "admin/group"
} }
@@ -198,38 +197,11 @@ class AdminGroupController(
id: String, id: String,
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" } ): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
/** Строки для блока «Защиты»: сессии защиты по всем лабораторным группы. */
private fun buildDefenseRows(
token: String,
group: GroupDetailDto,
labs: List<LabSummaryDto>,
): List<GroupDefenseRow> =
labs
.flatMap { lab ->
val byStudent = api.labDefenses(token, lab.id).associateBy { it.studentId }
group.students.mapNotNull { s ->
val d = byStudent[s.id] ?: return@mapNotNull null
GroupDefenseRow(
sessionId = d.id,
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
labId = lab.id,
labNumber = lab.number,
status = d.status,
code = d.code?.takeIf { d.status == "waiting" },
assignmentMd = d.assignmentMd,
secondsLeft = d.secondsLeft,
)
}
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber }))
private data class GroupViewModel( private data class GroupViewModel(
val group: GroupDetailDto, val group: GroupDetailDto,
val labs: List<LabSummaryDto>, val labs: List<LabSummaryDto>,
val candidates: List<StudentCandidateDto>, val candidates: List<StudentCandidateDto>,
val rows: List<GroupStudentRow>, val rows: List<GroupStudentRow>,
val defenseRows: List<GroupDefenseRow>,
val publishedLabs: Set<String>, val publishedLabs: Set<String>,
) )
@@ -241,7 +213,6 @@ class AdminGroupController(
model.addAttribute("labs", view.labs) model.addAttribute("labs", view.labs)
model.addAttribute("candidates", view.candidates) model.addAttribute("candidates", view.candidates)
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("defenseRows", view.defenseRows)
model.addAttribute("publishedLabIds", view.publishedLabs) model.addAttribute("publishedLabIds", view.publishedLabs)
} }

View File

@@ -51,6 +51,8 @@ data class GroupLabCell(
val defenseCode: String? = null, val defenseCode: String? = null,
val defenseSecondsLeft: Long? = null, val defenseSecondsLeft: Long? = null,
val defenseAssigned: Boolean = false, val defenseAssigned: Boolean = false,
val defenseSessionId: String? = null,
val defenseAssignmentMd: String? = null,
) )
/** Строка студента в матрице + комментарии для диалога. */ /** Строка студента в матрице + комментарии для диалога. */

View File

@@ -200,6 +200,33 @@ tr:last-child td { border-bottom: none; }
/* Toast-уведомление */ /* Toast-уведомление */
.toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; } .toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; }
/* Всплывающие уведомления (flash notice/error) вместо статичных алертов */
.toast-host {
position: fixed; top: 1rem; right: 1rem; z-index: 100;
display: flex; flex-direction: column; gap: 0.5rem;
width: min(24rem, calc(100vw - 2rem)); pointer-events: none;
}
.toast-item {
pointer-events: auto; display: flex; align-items: flex-start; gap: 0.5rem;
padding: 0.7rem 0.85rem; border-radius: 0.5rem; border: 1px solid var(--c-border);
border-left-width: 4px; background: var(--c-surface); color: var(--c-text);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
animation: toast-in 0.18s ease-out;
}
.toast-item .toast-text { flex: 1 1 auto; min-width: 0; white-space: pre-line; overflow-wrap: anywhere; }
.toast-item.toast-success { border-left-color: #16a34a; }
.toast-item.toast-error { border-left-color: #dc2626; }
.toast-item.toast-warning { border-left-color: #d97706; }
.toast-close {
flex: 0 0 auto; border: none; background: none; cursor: pointer;
color: var(--c-text-muted); font-size: 1.05rem; line-height: 1; padding: 0 0.1rem;
}
.toast-close:hover { color: var(--c-text); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
/* Modals */ /* Modals */
dialog.modal { dialog.modal {
border: none; border-radius: 0.75rem; padding: 0; border: none; border-radius: 0.75rem; padding: 0;

View File

@@ -65,9 +65,10 @@ function csSerialize() {
$id('grade-comments').value = JSON.stringify(out); $id('grade-comments').value = JSON.stringify(out);
} }
/* ---------- Индивидуальные задания (защита) ---------- */ /* ---------- Индивидуальное задание (защита) ---------- */
function openDefenseForm(btn) { /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
function openDefense(btn) {
var form = $id('defense-form'); var form = $id('defense-form');
if (!form) return; if (!form) return;
form.reset(); form.reset();
@@ -76,29 +77,41 @@ function openDefenseForm(btn) {
if (student) $id('defense-student-select').value = student; if (student) $id('defense-student-select').value = student;
if (lab) $id('defense-lab-select').value = lab; if (lab) $id('defense-lab-select').value = lab;
$id('defense-md').value = btn.getAttribute('data-md') || ''; $id('defense-md').value = btn.getAttribute('data-md') || '';
var session = btn.getAttribute('data-session') || '';
var status = btn.getAttribute('data-status') || '';
var actions = $id('defense-actions');
if (session) {
$id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code';
$id('defense-reset-form').action = '/admin/defenses/' + session + '/reset';
if ($id('defense-action-lab')) $id('defense-action-lab').value = lab;
if ($id('defense-reset-lab')) $id('defense-reset-lab').value = lab;
$id('defense-reset-form').hidden = status !== 'ready';
if (actions) actions.hidden = false;
} else if (actions) {
actions.hidden = true;
}
var d = $id('modal-defense'); var d = $id('modal-defense');
if (d && d.showModal) d.showModal(); if (d && d.showModal) d.showModal();
} }
function showDefenseText(btn) { function showDefenseFromModal() {
var el = $id('defense-text-body'); var el = $id('defense-text-body');
if (el) el.textContent = btn.getAttribute('data-md') || '(пусто)'; if (el) el.textContent = $id('defense-md').value || '(пусто)';
var title = $id('defense-text-title'); var title = $id('defense-text-title');
if (title) title.textContent = (btn.getAttribute('data-student-name') || '') + ' — ' + (btn.getAttribute('data-lab-name') || ''); if (title) {
var s = $id('defense-student-select');
var l = $id('defense-lab-select');
var who = s && s.selectedOptions[0] ? s.selectedOptions[0].textContent : '';
var lab = l && l.selectedOptions[0] ? l.selectedOptions[0].textContent : '';
title.textContent = who + ' — ' + lab;
}
var d = $id('modal-defense-text'); var d = $id('modal-defense-text');
if (d && d.showModal) d.showModal(); if (d && d.showModal) d.showModal();
} }
function filterDefense() { /* ---------- Матрица: фокус на лабораторной ---------- */
var s = $id('defense-lab-filter');
if (!s) return;
var labId = s.value;
document.querySelectorAll('#defense-table tr[data-lab]').forEach(function (tr) {
tr.style.display = (!labId || tr.getAttribute('data-lab') === labId) ? '' : 'none';
});
}
/* ---------- Матрица: фокус на лабораторной и закрепление варианта ---------- */
function focusLab() { function focusLab() {
var s = $id('lab-focus'); var s = $id('lab-focus');
@@ -113,4 +126,4 @@ document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'grade-form') csSerialize(); if (ev.target && ev.target.id === 'grade-form') csSerialize();
}, true); }, true);
window.addEventListener('DOMContentLoaded', function () { filterDefense(); focusLab(); }); window.addEventListener('DOMContentLoaded', function () { focusLab(); });

View File

@@ -14,22 +14,18 @@
<button class="btn btn-secondary" type="button" <button class="btn btn-secondary" type="button"
th:disabled="${#lists.isEmpty(candidates)}" th:disabled="${#lists.isEmpty(candidates)}"
onclick="openModal('modal-enroll-student')">Добавить существующего</button> 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/' + ${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='xlsx')}">Логины XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div> </div>
</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"> <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)}"> <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="Поиск по студенту"> placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<label class="label m-0" for="lab-focus">Показать:</label> <label class="label m-0" for="lab-focus">Показать:</label>
<select class="input input-sm" id="lab-focus" onchange="focusLab()"> <select class="input input-sm" id="lab-focus" onchange="focusLab()">
@@ -39,14 +35,14 @@
</div> </div>
</div> </div>
<p class="text-sm text-muted mb-4"> <p class="text-sm text-muted mb-4">
Значки в ячейке: «№» — вариант, «🛡» — защита, «↓» — сдача (скачать), «✎» — оценка. Ячейка: «№» — вариант, «🛡» — защита (клик — задание и код), «↓» — сдача, «✎» — оценка.
Наведите на значок — появится пояснение; клик по «↓» скачивает последнее решение, Раздача вариантов — на странице лабораторной.
клик по «✎» открывает оценку и комментарии. Раздача вариантов — на странице лабораторной.
</p> </p>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div> <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"> <div class="scroll-x">
<table class="matrix tablekit" id="matrix-table"> <table class="matrix tablekit" id="group-table">
<thead> <thead>
<tr> <tr>
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th> <th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
@@ -67,6 +63,10 @@
</div> </div>
</div> </div>
</th> </th>
<th data-filter>Основа</th>
<th>Варианты</th>
<th data-filter>Пароль</th>
<th class="text-right" data-nosort>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -83,11 +83,12 @@
№<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''" №<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''"
th:text="${cell.variantNumber} ?: '—'">12</span> th:text="${cell.variantNumber} ?: '—'">12</span>
</span> </span>
<span th:if="${cell.defenseStatus == null}" class="cell-chip chip-dim" title="Защита не назначена">🛡—</span> <button class="cell-chip" type="button"
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}" class="cell-chip chip-warn" title="Задание для защиты выдано, ещё без кода">🛡⌛</span> th:classappend="${cell.defenseStatus == null} ? 'chip-dim' : (${cell.defenseStatus == 'ready'} ? 'chip-ok' : 'chip-warn')"
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}" class="cell-chip chip-warn" title="Защита: код студенту выдан, ждёт входа">🛡🔑</span> 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}"
<span th:if="${cell.defenseStatus == 'started'}" class="cell-chip chip-warn" title="Защита: студент пишет работу">🛡⏳</span> onclick="openDefense(this)"
<span th:if="${cell.defenseStatus == 'ready'}" class="cell-chip chip-ok" title="Защита: работа сохранена, можно принимать">🛡✓</span> 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" <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})}" 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> 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> >✎<span class="font-mono" th:if="${cell.score}" th:text="' ' + ${cell.score}"></span></button>
</div> </div>
</td> </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>
<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> </tbody>
</table> </table>
</div> </div>
</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"> <dialog id="modal-grade" class="modal">
<div class="modal-header"> <div class="modal-header">
@@ -231,7 +152,7 @@
</div> </div>
</dialog> </dialog>
<!-- Диалог: назначение индивидуального задания --> <!-- Диалог: индивидуальное задание (защита) -->
<dialog id="modal-defense" class="modal"> <dialog id="modal-defense" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2> <h2 class="modal-title">Индивидуальное задание</h2>
@@ -256,6 +177,24 @@
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div> <textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить задание</button> <button class="btn btn-primary" type="submit">Сохранить задание</button>
</form> </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> </div>
</dialog> </dialog>
@@ -359,7 +298,6 @@
</div> </div>
</div> </div>
<script src="/js/admin-group.js" defer></script> <script src="/js/admin-group.js" defer></script>
</div> </div>
</body> </body>

View File

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

View File

@@ -5,8 +5,6 @@
<div id="content"> <div id="content">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a> <a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
<h1 class="page-title mt-4">Настройка лабораторной</h1> <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})}"> <form class="form-stack" method="post" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <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> <button class="btn btn-primary" type="button" onclick="openModal('modal-distribute')">Распределить</button>
</div> </div>
</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> <h2 class="section-title">Условие лабораторной</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div> <div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>

View File

@@ -14,8 +14,6 @@
</div> </div>
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна). <p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p> <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"> <div class="card card-flush">
<table id="labs-table" class="tablekit"> <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 class="text-sm text-muted mb-4" th:if="${currentUser == null or !currentUser.mustChangePassword}">
Смените пароль, если хотите. После сохранения вы останетесь в системе. Смените пароль, если хотите. После сохранения вы останетесь в системе.
</p> </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"> <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="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> <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 LMS v0.2.0
</footer> </footer>
</div> </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> </body>
</html> </html>

View File

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

View File

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

View File

@@ -6,7 +6,6 @@
<div class="container-sm"> <div class="container-sm">
<h1 class="page-title">Создание администратора</h1> <h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p> <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"> <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="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> <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> <span th:if="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span>
</p> </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> <h2 class="section-title">Условие лабораторной</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div> <div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>