Lab assignment management UI and student variant view

- progress-service: POST /api/v1/assign (manual variant), GET /api/v1/assignments?labId=
  (enriched assignments), fixed enrich() to return real variant number/title/statement
- gateway: routes for /api/v1/assign and /api/v1/levels (exact paths)
- web: lab detail page shows students grouped with level and variant selects,
  group filter, distribute/redistribute buttons, source badge (auto/manual)
- web: student dashboard renders variant statement via libs/markdown (spoilers,
  code highlight, sanitized)
- academic-service: unquote YAML front-matter values (variant numbers were quoted)
- academic-service: LabLevelDetail.id for level references
This commit is contained in:
2026-09-10 23:25:36 +03:00
parent 0fdb9faab6
commit feed600d43
17 changed files with 408 additions and 67 deletions

View File

@@ -3,13 +3,20 @@
<head><title th:text="${lab.title} + ' — LMS'">Лабораторная</title></head>
<body>
<div id="content">
<a th:href="@{/admin/labs}" class="text-sm">← Все лабораторные</a>
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<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></div>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить (сбросить ручные)</button>
</form>
<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>
</div>
<div class="flex gap-2">
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
</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>
</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>
@@ -20,24 +27,73 @@
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
<tbody>
<tr th:each="level : ${lab.levels}">
<td th:text="${level.code}">—</td>
<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>
<p class="text-sm text-muted mb-4">Нажмите «Распределить», чтобы закрепить варианты. Можно задать уровень студента.</p>
<div class="card card-flush">
<table>
<thead><tr><th>Студент</th><th>Уровень</th><th>Вариант</th></tr></thead>
<tbody id="student-rows">
<!-- Filled dynamically after distribution -->
</tbody>
</table>
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title">Студенты и варианты</h2>
<label class="text-sm text-muted">Группа:
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
<option value="">Все</option>
<option th:each="g : ${groups}" th:value="${g.groupName}" th:text="${g.groupName}">—</option>
</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">
<table>
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th></tr></thead>
<tbody>
<tr th:each="s : ${g.students}">
<td th:text="${s.fullName}">—</td>
<td class="font-mono" th:text="${s.studentNo}">—</td>
<td>
<form class="inline-form" th:action="@{/admin/labs/{labId}/set-level(labId=${lab.id})}" method="post">
<input type="hidden" name="studentId" th:value="${s.studentId}">
<select class="input input-sm" name="labLevelId">
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}"
th:selected="${level.id == s.levelId}">—</option>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
</form>
</td>
<td>
<form class="inline-form" th:action="@{/admin/labs/{labId}/assign(labId=${lab.id})}" method="post">
<input type="hidden" name="studentId" th:value="${s.studentId}">
<select class="input input-sm" name="variantId">
<optgroup th:each="level : ${lab.levels}" th:label="${level.title}">
<option th:each="v : ${level.variants}" th:value="${v.id}"
th:text="|${v.number} — ${v.title}|"
th:selected="${v.id == s.variantId}">—</option>
</optgroup>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
</form>
</td>
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
<td><span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-warn' : 'badge-ok'" th:text="${s.source}">—</span></td>
</tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="6" class="empty">В группе нет студентов.</td></tr>
</tbody>
</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>
</body>
</html>