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:
@@ -158,6 +158,28 @@ tr:last-child td { border-bottom: none; }
|
||||
/* Theme toggle */
|
||||
.theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
|
||||
/* Group assignment page */
|
||||
.group-title { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
|
||||
.inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; }
|
||||
.inline-form .input-sm { width: auto; min-width: 10rem; }
|
||||
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
|
||||
.filter-select { width: auto; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Markdown body */
|
||||
.md-body { font-size: 0.9375rem; }
|
||||
.md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; }
|
||||
.md-body h1 { font-size: 1.25rem; }
|
||||
.md-body h2 { font-size: 1.125rem; }
|
||||
.md-body p, .md-body ul, .md-body ol { margin: 0.5rem 0; }
|
||||
.md-body ul, .md-body ol { padding-left: 1.5rem; }
|
||||
.md-body pre { background: var(--c-bg); border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.75rem 1rem; overflow-x: auto; font-size: 0.8125rem; }
|
||||
.md-body code { font-family: monospace; }
|
||||
.md-body details.spoiler { border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.5rem 0.75rem; margin: 0.5rem 0; }
|
||||
.md-body details.spoiler summary { cursor: pointer; font-weight: 500; color: var(--c-text-link); }
|
||||
.md-body img { max-width: 100%; }
|
||||
.md-body table { margin: 0.5rem 0; }
|
||||
|
||||
/* Empty state */
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -9,28 +9,23 @@
|
||||
<div class="grid grid-2">
|
||||
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
|
||||
<h2 class="section-title">Импорт лабораторной</h2>
|
||||
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json (старый формат).</p>
|
||||
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json.</p>
|
||||
<div><label class="label" for="labFile">Файл</label><input class="input" id="labFile" name="file" type="file" accept=".zip,.json" required></div>
|
||||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card card-flush mt-8">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Семестр</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="lab : ${labs}">
|
||||
<td th:text="${lab.number}">1</td>
|
||||
<td th:text="${lab.title}">—</td>
|
||||
<td class="text-muted" th:text="${lab.semesterTitle}">—</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><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td>
|
||||
<td class="text-center">
|
||||
<form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post" onsubmit="return confirm('Удалить лабораторную ${lab.title}?')">
|
||||
<button class="nav-link text-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><button class="nav-link text-sm" type="submit">Удалить</button></form></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
|
||||
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<div class="page-header-inner">
|
||||
<a th:href="@{/}" class="logo">LMS</a>
|
||||
<nav class="nav" th:if="${currentUser != null}">
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link">Панель</a>
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
|
||||
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
||||
|
||||
@@ -5,23 +5,25 @@
|
||||
<div id="content">
|
||||
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
|
||||
<p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
|
||||
|
||||
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
|
||||
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
|
||||
</div>
|
||||
<div class="card card-flush" th:unless="${#lists.isEmpty(assignments)}">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Вариант</th><th>Срок сдачи</th><th>Дата защиты</th><th>Статус</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="a : ${assignments}">
|
||||
<td th:text="${a.labNumber}">1</td>
|
||||
<td th:text="${a.labTitle}">—</td>
|
||||
<td th:text="${a.variantNumber}">—</td>
|
||||
<td class="text-sm text-muted" th:text="${a.deadlineAt}">—</td>
|
||||
<td class="text-sm text-muted" th:text="${a.defenseAt}">—</td>
|
||||
<td><span class="badge badge-warn">назначено</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div th:each="a : ${assignments}" class="card mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 class="section-title" th:text="|ЛР ${a.labNumber}. ${a.labTitle}|">ЛР</h2>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="badge badge-ok">Вариант <span class="font-mono" th:text="${a.variantNumber}">—</span></span>
|
||||
<span th:if="${a.levelTitle}" class="badge badge-warn" th:text="${a.levelTitle}">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted" th:if="${a.variantTitle}" th:text="${a.variantTitle}">—</p>
|
||||
<p class="text-sm text-muted" th:if="${a.deadlineAt}" th:text="|Срок сдачи: ${a.deadlineAt}|">—</p>
|
||||
<p class="text-sm text-muted" th:if="${a.defenseAt}" th:text="|Защита: ${a.defenseAt}|">—</p>
|
||||
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user