feat(web): кабинет преподавателя и управление преподавателями

This commit is contained in:
2026-10-27 15:47:01 +03:00
parent 8cc658bf97
commit dd1129fe80
17 changed files with 602 additions and 12 deletions

View File

@@ -75,10 +75,14 @@
<h2 class="section-title">Преподаватели</h2>
<div class="card card-flush">
<table>
<thead><tr><th>Аккаунт</th><th>Назначен</th><th data-nosort></th></tr></thead>
<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>
<span class="font-medium"
th:text="${teacherNames[teacher.accountId] ?: teacher.accountId}">—</span>
<div class="text-sm text-muted font-mono" th:text="${teacher.accountId}">—</div>
</td>
<td class="text-muted" th:text="${teacher.assignedAt}">—</td>
<td class="text-center">
<form class="inline-form"
@@ -98,11 +102,27 @@
<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>
<div><label class="label" for="teacherAccount">Преподаватель</label>
<select class="input" id="teacherAccount" name="accountId" required
th:disabled="${#lists.isEmpty(availableTeachers)}">
<option value="">— выберите преподавателя —</option>
<option th:each="t : ${availableTeachers}" th:value="${t.id}"
th:text="${#strings.isEmpty(t.name) ? t.login : t.name + ' (' + t.login + ')'}">—</option>
</select>
<p class="hint" th:if="${#lists.isEmpty(teachers)}">
Преподавателей ещё нет.
<a th:href="@{/admin/teachers}">Создайте первого на странице «Преподаватели»</a>.
</p>
<p class="hint" th:if="${!#lists.isEmpty(teachers) and #lists.isEmpty(availableTeachers)}">
Все преподаватели уже назначены на курс.
</p>
<p class="hint" th:if="${!#lists.isEmpty(availableTeachers)}">
Нет нужного?
<a th:href="@{/admin/teachers}">Создать преподавателя на странице «Преподаватели»</a>.
</p>
</div>
<button class="btn btn-primary" type="submit"
th:disabled="${#lists.isEmpty(availableTeachers)}">Назначить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>

View File

@@ -0,0 +1,70 @@
<!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">
<h1 class="page-title">Преподаватели</h1>
<form class="card card-body form-stack mb-6" th:action="@{/admin/teachers}" method="post">
<h2 class="section-title m-0">Новый преподаватель</h2>
<div class="row g-3">
<div class="col-md-6">
<label class="label" for="teacherLogin">Логин</label>
<input class="input" id="teacherLogin" name="login" autocomplete="off"
placeholder="ivanov" required minlength="3">
</div>
<div class="col-md-6">
<label class="label" for="teacherPassword">Пароль (необязательно)</label>
<input class="input" id="teacherPassword" name="password" type="password"
autocomplete="new-password" placeholder="Оставьте пустым — сгенерируем">
</div>
</div>
<p class="hint">
Логин — минимум 3 символа. Если пароль не указан, сервис сгенерирует его.
Одноразовый пароль будет показан один раз после создания — передайте его преподавателю.
</p>
<button class="btn btn-primary" type="submit">Создать преподавателя</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<div class="card card-flush">
<table>
<thead>
<tr>
<th>Преподаватель</th>
<th>Логин</th>
<th>Статус</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="t : ${teachers}">
<td>
<span class="font-medium" th:text="${#strings.isEmpty(t.name) ? t.login : t.name}">—</span>
<div class="text-sm text-muted font-mono" th:text="${t.id}">—</div>
</td>
<td class="font-mono" th:text="${t.login}">—</td>
<td>
<span class="badge" th:classappend="${t.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${t.status == 'ACTIVE' ? 'активен' : t.status}">—</span>
<span class="badge badge-warn" th:if="${t.mustChangePassword}">временный пароль</span>
</td>
<td class="text-right">
<form class="inline-form"
th:action="@{/admin/teachers/{id}/reset-password(id=${t.id})}"
method="post" data-confirm="Сбросить пароль преподавателя? Старый пароль перестанет действовать.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="login" th:value="${t.login}">
<button class="btn btn-sm btn-secondary" type="submit">Сбросить пароль</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(teachers)}">
<td colspan="4" class="empty">Преподавателей пока нет. Создайте первого в форме выше.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -51,13 +51,16 @@
<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/teachers}">Преподаватели</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>
<li class="nav-item" th:if="${currentUser != null and currentUser.teacher}"><a class="nav-link" th:href="@{/teacher}">Мои курсы</a></li>
<li class="nav-item" th:if="${currentUser != null and currentUser.student}"><a class="nav-link" th:href="@{/student/labs}">Лабораторные</a></li>
<li class="nav-item" th:if="${currentUser != null}"><a class="nav-link" th:href="@{/change-password}">Смена пароля</a></li>
</ul>
<div class="navbar-user d-flex flex-column flex-md-row align-items-start align-items-md-center gap-2">
<a th:href="@{/admin}" th:if="${currentUser != null and currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:if="${currentUser != null and !currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/teacher}" th:if="${currentUser != null and currentUser.teacher}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:if="${currentUser != null and currentUser.student}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-secondary btn-sm theme-btn" aria-label="Сменить тему" title="Сменить тему">
<i class="bi bi-circle-half"></i>
@@ -97,6 +100,13 @@
</span>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div th:if="${teacherPassword}" class="toast-item toast-warning" data-toast>
<span class="toast-text">
Одноразовый пароль<span th:if="${teacherLogin}" th:text="| для ${teacherLogin}|"> для login</span>:
<code class="code font-mono" th:text="${teacherPassword}">password</code>
</span>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
</div>
<dialog id="modal-confirm" class="dlg">
<div class="dlg-header">

View File

@@ -0,0 +1,92 @@
<!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="@{/teacher}" class="nav-link">← Мои курсы</a>
<div class="page-head mb-2">
<div>
<h1 class="page-title" th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</h1>
<div class="meta">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
</div>
</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></tr></thead>
<tbody>
<tr th:each="group : ${groups}">
<td>
<a th:if="${currentUser.admin}"
th:href="@{/admin/groups/{id}(id=${group.groupId})}"
class="font-mono"
th:text="${group.groupId}">—</a>
<span th:unless="${currentUser.admin}" class="font-mono"
th:text="${group.groupId}">—</span>
</td>
<td class="text-muted" th:text="${group.addedAt}">—</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}">
<td colspan="2" class="empty">К курсу не привязано ни одной группы.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint mt-2">Состав групп и привязку к курсу изменяет администратор.</p>
</div>
<div class="col-lg-6">
<h2 class="section-title">Сведения о курсе</h2>
<div class="card card-body">
<p class="text-sm">Создан: <span th:text="${course.createdAt}">—</span></p>
<p class="text-sm">Обновлён: <span th:text="${course.updatedAt}">—</span></p>
<p class="text-sm" th:if="${!#lists.isEmpty(course.teachers)}">
Преподавателей на курсе: <span th:text="${#lists.size(course.teachers)}">0</span>
</p>
</div>
</div>
</div>
<h2 class="section-title mt-8 mb-2">Лабораторные курса</h2>
<div class="card card-flush">
<table>
<thead>
<tr><th>№</th><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>
<a th:if="${currentUser.admin}"
th:href="@{/admin/labs/{id}(id=${lab.id})}"
th:text="${lab.title}">—</a>
<span th:unless="${currentUser.admin}" th:text="${lab.title}">—</span>
</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-muted" th:text="${lab.deadlineMsk ?: '—'}">—</td>
<td class="text-muted" th:text="${lab.defenseMsk ?: '—'}">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}">
<td colspan="6" class="empty">В курсе пока нет лабораторных.</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!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">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">преподаватель</span></h1>
<p class="meta">Курсы, на которые вы назначены.</p>
<div class="row row-cols-1 row-cols-lg-2 g-4 mt-4">
<div th:each="course : ${courses}" class="col">
<div class="card h-100">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/teacher/courses/{id}(id=${course.id})}"
th:text="|Курс ${course.courseNo}. ${course.title}|">Курс</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
<span class="badge"
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
<span class="badge" th:text="|групп: ${course.groupCount}|">групп: 0</span>
<span class="badge" th:text="|лабораторных: ${course.labCount}|">лабораторных: 0</span>
</div>
<div class="mt-4">
<a class="btn btn-sm btn-secondary"
th:href="@{/teacher/courses/{id}(id=${course.id})}">Открыть курс</a>
</div>
</div>
</div>
</div>
<div th:if="${#lists.isEmpty(courses)}" class="col">
<div class="card card-body text-muted">
Вам пока не назначено ни одного курса. Обратитесь к администратору.
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-8">
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div>
</div>
</body>
</html>