feat(web): страницы курсов и привязка лабораторных

This commit is contained in:
2026-10-27 15:37:52 +03:00
parent 0c51bf2d12
commit 9a539407f1
11 changed files with 837 additions and 11 deletions

View File

@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${course.title} + ' — LMS'">Курс</title></head>
<body>
<div id="content">
<a th:href="@{/admin/courses}" class="nav-link">← Все курсы</a>
<div class="page-head mb-2">
<div>
<h1 class="page-title" th:text="|Курс ${course.courseNo}. ${course.title}|">Курс 1</h1>
<div class="meta mb-2">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<form class="inline-form" th:action="@{/admin/courses/{id}/status(id=${course.id})}" method="post"
th:attr="data-confirm=${course.status == 'ACTIVE' ? 'Переместить курс в архив? Привязка групп и преподавателей станет недоступна.' : 'Вернуть курс в работу?'}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="status" th:value="${course.status == 'ACTIVE' ? 'ARCHIVED' : 'ACTIVE'}">
<button class="btn btn-sm" type="submit"
th:text="${course.status == 'ACTIVE' ? 'В архив' : 'Вернуть в работу'}">В архив</button>
</form>
<span th:text="|Владелец: ${course.ownerAccountId}|">Владелец: —</span>
</div>
<span th:text="|Создан: ${course.createdAt}|">Создан: —</span>
· <span th:text="|Обновлён: ${course.updatedAt}|">Обновлён: —</span>
</div>
</div>
<div class="row g-4 mt-4">
<div class="col-lg-6">
<h2 class="section-title">Группы курса</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Группа</th><th>Добавлена</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="attached : ${attachedGroups}">
<td>
<a th:href="@{/admin/groups/{id}(id=${attached.groupId})}"
th:text="${groupNames[attached.groupId] ?: attached.groupId}">—</a>
</td>
<td class="text-muted" th:text="${attached.addedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/groups/{groupId}/detach(id=${course.id}, groupId=${attached.groupId})}"
method="post" data-confirm="Отвязать группу от курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отвязать</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(attachedGroups)}">
<td colspan="3" class="empty">Группы к курсу не привязаны.</td>
</tr>
</tbody>
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/groups(id=${course.id})}"
method="post">
<div><label class="label" for="courseGroup">Привязать группу</label>
<select class="input" id="courseGroup" name="groupId" required
th:disabled="${#lists.isEmpty(availableGroups)}">
<option value="">— выберите группу —</option>
<option th:each="group : ${availableGroups}" th:value="${group.id}"
th:text="${group.name}">—</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(availableGroups)}">Все доступные группы уже привязаны.</p>
</div>
<button class="btn btn-primary" type="submit"
th:disabled="${#lists.isEmpty(availableGroups)}">Привязать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="col-lg-6">
<h2 class="section-title">Преподаватели</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Аккаунт</th><th>Назначен</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="teacher : ${activeTeachers}">
<td class="font-mono" th:text="${teacher.accountId}">—</td>
<td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center">
<form class="inline-form"
th:action="@{/admin/courses/{id}/teachers/{accountId}/remove(id=${course.id}, accountId=${teacher.accountId})}"
method="post" data-confirm="Снять преподавателя с курса?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Снять</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(activeTeachers)}">
<td colspan="3" class="empty">Преподаватели не назначены.</td>
</tr>
</tbody>
</table>
</div>
<form class="card card-body form-stack mt-4" th:action="@{/admin/courses/{id}/teachers(id=${course.id})}"
method="post">
<div><label class="label" for="teacherAccount">Идентификатор аккаунта преподавателя</label>
<input class="input font-mono" id="teacherAccount" name="accountId" required
placeholder="UUID аккаунта"></div>
<p class="hint">UUID можно скопировать на странице «Пользователи».</p>
<button class="btn btn-primary" type="submit">Назначить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mt-4 mb-2">
<h2 class="section-title m-0">Лабораторные курса</h2>
<a class="text-sm" th:href="@{/admin/labs(courseId=${course.id})}">Открыть в списке лабораторных →</a>
</div>
<div class="card card-flush">
<table>
<thead><tr><th>№</th><th>Название</th><th data-filter>Статус</th><th>Вариантов</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td>
<span class="badge"
th:classappend="${lab.status == 'published' ? 'badge-ok' : 'badge-warn'}"
th:text="${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">—</span>
</td>
<td th:text="${lab.variantCount}">0</td>
<td class="text-center">
<a class="btn btn-sm btn-secondary" th:href="@{/admin/labs/{id}(id=${lab.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">В курсе пока нет лабораторных.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Курсы — LMS</title></head>
<body>
<div id="content">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="page-title m-0">Курсы</h1>
<div class="flex gap-2">
<input class="input table-search" data-target="courses-table"
placeholder="Поиск по названию…" aria-label="Поиск по курсам">
<button class="btn btn-primary" type="button" data-modal="modal-create-course">+ Новый курс</button>
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="card card-body mb-6">
<h2 class="section-title">Первичная настройка курса</h2>
<p class="text-sm text-muted">
Курсов ещё нет. Bootstrap создаст курс «Промышленное программирование на языке C»,
перенесёт в него все лабораторные и главы и привяжет все активные группы
(<span th:text="${activeGroupCount}">0</span>).
</p>
<form class="form-stack" th:action="@{/admin/courses/bootstrap}" method="post"
data-confirm="Создать курс и перенести в него все лабораторные, главы и активные группы?">
<div><label class="label" for="bootstrapTitle">Название курса (необязательно)</label>
<input class="input" id="bootstrapTitle" name="title"
placeholder="Промышленное программирование на языке C"></div>
<button class="btn btn-primary" type="submit">Создать курс и перенести данные</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="card card-flush">
<table id="courses-table" class="tablekit">
<thead>
<tr>
<th class="th-sortable is-asc" title="Номер — по возрастанию">№</th>
<th class="th-sortable" title="Название — по возрастанию">Название</th>
<th data-filter>Статус</th>
<th>Преподавателей</th>
<th>Групп</th>
<th>Лабораторных</th>
<th data-nosort></th>
</tr>
</thead>
<tbody>
<tr th:each="course : ${courses}">
<td th:text="${course.courseNo}">1</td>
<td><a th:href="@{/admin/courses/{id}(id=${course.id})}" th:text="${course.title}">—</a></td>
<td th:attr="data-filter-value=${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
</td>
<td th:text="${course.teacherCount}">0</td>
<td th:text="${course.groupCount}">0</td>
<td th:text="${course.labCount}">0</td>
<td class="text-center">
<a class="btn btn-sm btn-secondary" th:href="@{/admin/courses/{id}(id=${course.id})}">Открыть</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(courses)}"><td colspan="7" class="empty">Курсов пока нет.</td></tr>
</tbody>
</table>
</div>
<dialog id="modal-create-course" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новый курс</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/courses}" method="post">
<div><label class="label" for="courseTitle">Название</label>
<input class="input" id="courseTitle" name="title" placeholder="Новый курс" autofocus></div>
<p class="hint">Номер курса присвоится автоматически. Название можно оставить пустым —
тогда будет использовано «Новый курс».</p>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</div>
</body>
</html>

View File

@@ -19,6 +19,13 @@
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать
</button>
</form>
<button class="btn btn-secondary btn-sm" type="button" data-modal="modal-copy-lab">Копировать в курс</button>
</div>
<div class="mb-2">
<span th:if="${lab.courseId != null}">Курс:
<a th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a></span>
<span th:if="${lab.courseId == null}" class="text-muted">Курс не указан</span>
</div>
<span th:if="${deadlineMsk != null}">Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
@@ -196,6 +203,36 @@
</dialog>
</th:block>
</div>
<dialog id="modal-copy-lab" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Копировать лабораторную в курс</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/copy(id=${lab.id})}" method="post">
<div><label class="label" for="copyCourseId">Целевой курс</label>
<select class="input" id="copyCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:if="${lab.courseId == null or course.id != lab.courseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
</div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="copyLabNumber">Номер (необязательно)</label>
<input class="input" id="copyLabNumber" name="number" type="number" min="1"
th:placeholder="${lab.number}"></div>
<div class="col-md-6"><label class="label" for="copyLabTitle">Название (необязательно)</label>
<input class="input" id="copyLabTitle" name="title" th:placeholder="${lab.title}"></div>
</div>
<p class="hint">Копируются условие, уровни и варианты. Сдачи, оценки и назначения студентов
не переносятся; копия создаётся черновиком.</p>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Копировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<script th:src="@{/js/admin-lab.js(v='37')}" defer></script>
</div>
</body>

View File

@@ -5,7 +5,16 @@
<div id="content">
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2 items-center">
<form method="get" th:action="@{/admin/labs}" class="m-0">
<select class="input" name="courseId" aria-label="Фильтр по курсу" onchange="this.form.submit()">
<option value="" th:selected="${selectedCourseId == null}">Все курсы</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:selected="${course.id == selectedCourseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<noscript><button class="btn btn-secondary" type="submit">Фильтр</button></noscript>
</form>
<input class="input table-search" data-target="labs-table"
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" data-modal="modal-import-lab">Импорт</button>
@@ -13,13 +22,25 @@
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="card card-body mb-6">
<p class="text-sm text-muted m-0">
Курсов ещё нет. Сначала создайте курс на странице
<a th:href="@{/admin/courses}">«Курсы»</a> — все лабораторные принадлежат курсу.
</p>
</div>
<div class="card card-flush">
<table id="labs-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Курс</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<tbody>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td>
<a th:if="${lab.courseId != null}" th:href="@{/admin/courses/{id}(id=${lab.courseId})}"
th:text="|${lab.courseNo}. ${lab.courseTitle}|">1. Курс</a>
<span th:if="${lab.courseId == null}" class="text-muted">—</span>
</td>
<td th:text="${lab.variantCount}">0</td>
<td th:attr="data-filter-value=${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">
<span class="badge"
@@ -43,7 +64,7 @@
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="7" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
</table>
</div>
@@ -56,6 +77,14 @@
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
<div><label class="label" for="importCourseId">Курс</label>
<select class="input" id="importCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(courses)}">Сначала создайте курс на странице «Курсы».</p>
</div>
<div><label class="label" for="labFile">Файл</label>
<input class="input" id="labFile" type="file" name="file" accept=".zip" required></div>
<p class="hint">
@@ -66,7 +95,7 @@
<code class="code">deploy/IMPORT.md</code>.
</p>
<p class="hint">Даты сдачи и защиты после импорта задаются на карточке лабораторной.</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
@@ -79,6 +108,15 @@
</div>
<div class="dlg-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div><label class="label" for="createCourseId">Курс</label>
<select class="input" id="createCourseId" name="courseId" required>
<option value="">— выберите курс —</option>
<option th:each="course : ${courses}" th:value="${course.id}"
th:selected="${course.id == selectedCourseId}"
th:text="|${course.courseNo}. ${course.title}|">1. Курс</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(courses)}">Сначала создайте курс на странице «Курсы».</p>
</div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
@@ -103,7 +141,7 @@
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<button class="btn btn-primary" type="submit" th:disabled="${#lists.isEmpty(courses)}">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>

View File

@@ -49,6 +49,7 @@
<div class="offcanvas-body">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/groups}">Группы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/courses}">Курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/users}">Пользователи</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.admin}"><a class="nav-link" th:href="@{/admin/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null and !currentUser.admin}"><a class="nav-link" th:href="@{/student/labs}">Лабораторные</a></li>