UI rework: modal dialogs, inline lab editing, manual creation, content management

- Dashboard: real stats (groups, students, labs, average grade), labs/groups
  summary lists, no placeholder tile
- Lab page: view + edit combined — metadata form in modal, condition rendered
  as markdown, per-level variant tables with add/edit/delete dialogs,
  level rename/delete, student level/variant selectors with group filter
- Labs list: create-lab dialog with manual level entry (code; title lines)
- Groups: create-group dialog, add-student dialog (auto password, toast card)
- Content: public /learn reader is the single place; admin buttons (new
  article with chapter/section create-or-select, rename/delete chapters and
  sections, move articles between sections, import zip) appear for admins;
  removed separate 'Контент' admin section
- Buttons use btn classes instead of nav-link
- Favicon: generated SVG book icon
- Security: CSRF synchronizer token interceptor (replaced spring-security in
  web which broke logback under nested jars), session regenerated on login,
  hardened session cookie, gateway actuator limited to health,info
- Jackson 3: nullable-safe request DTOs for labs (FAIL_ON_NULL_FOR_PRIMITIVES)
- academic: POST /labs (manual create with levels), level add/rename/delete,
  content chapters/sections CRUD, article create/move/delete endpoints
This commit is contained in:
2026-09-11 16:00:56 +03:00
parent cca20b5416
commit 08890875e5
45 changed files with 2435 additions and 86 deletions

View File

@@ -6,39 +6,83 @@
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
<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">Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></p>
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></span>
<span th:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
</p>
</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-secondary" type="button" onclick="openModal('modal-meta')">Править описание</button>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</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-flush mt-2 mb-6">
<table>
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
<tbody>
<tr th:each="level : ${lab.levels}">
<td class="font-mono" th:text="${level.code}">—</td>
<td th:text="${level.title}">—</td>
<td th:text="${#lists.size(level.variants)}">0</td>
</tr>
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровни не найдены.</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">Условие</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title">Студенты и варианты</h2>
<h2 class="section-title">Уровни и варианты</h2>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-level')">+ Уровень</button>
</div>
<div th:each="level : ${lab.levels}" class="card mb-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<h3 class="group-title">
<span class="font-mono text-muted" th:text="${level.code}">basic</span> —
<span th:text="${level.title}">Уровень</span>
(<span th:text="${#lists.size(level.variants)}">0</span> вар.)
</h3>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-level-'+${level.id})">Переименовать</button>
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
onsubmit="return confirm('Удалить уровень без вариантов?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<button class="btn btn-primary btn-sm" type="button"
th:onclick="openModal('modal-add-variant-'+${level.id})">+ Вариант</button>
</div>
</div>
<table>
<thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
<tbody>
<tr th:each="v : ${level.variants}">
<td class="font-mono" th:text="${v.number}">—</td>
<td th:text="${v.title}">—</td>
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
<td class="text-center">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-edit-variant-'+${v.id})">Править</button>
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
<h2 class="section-title">Студенты и варианты</h2>
<div class="flex items-center justify-between gap-4 mb-4">
<p class="text-sm text-muted">Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.</p>
<label class="text-sm text-muted">Группа:
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
<option value="">Все</option>
@@ -46,8 +90,6 @@
</select>
</label>
</div>
<p class="text-sm text-muted mb-4">Уровень по умолчанию — самый простой. Селекты позволяют выдать вариант посложнее или назначить вариант вручную.</p>
<div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}">
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6">
@@ -65,6 +107,7 @@
th:selected="${level.id == s.levelId}">—</option>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
<td>
@@ -78,6 +121,7 @@
</optgroup>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
@@ -88,13 +132,126 @@
</table>
</div>
</div>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
</div>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="metaTitle">Название</label>
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="metaDeadline">Срок сдачи</label>
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="metaDefense">Дата защиты</label>
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="metaPoints">Максимум баллов</label>
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="metaStatus">Статус</label>
<select class="input" id="metaStatus" 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>
</select></div>
</div>
<div><label class="label" for="metaDesc">Условие (markdown)</label>
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-add-level" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить уровень</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="lvlCode">Код</label>
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
<div><label class="label" for="lvlTitle">Название</label>
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
</div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать уровень</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-level-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="levelId" th:value="${level.id}">
<input type="hidden" name="code" th:value="${level.code}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${level.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-add-variant-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" required></div>
<div><label class="label">Название</label><input class="input" name="title"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<th:block th:each="level : ${lab.levels}">
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка варианта</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-variant-'+[[${v.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</th:block>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
</body>
</html>