305 lines
22 KiB
HTML
305 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
|
||
<body>
|
||
<div id="content">
|
||
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
|
||
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1>
|
||
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
|
||
<button class="btn btn-secondary" type="button"
|
||
th:disabled="${#lists.isEmpty(candidates)}"
|
||
onclick="openModal('modal-enroll-student')">Добавить существующего</button>
|
||
<button class="btn btn-secondary" type="button" onclick="openDefense(this)">Назначить задание</button>
|
||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
|
||
<a class="btn btn-secondary" th:href="'/admin/groups/' + ${group.id} + '/submissions.zip'">Скачать все работы</a>
|
||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Логины XLSX</a>
|
||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||
<h2 class="section-title m-0">Студенты и лабораторные</h2>
|
||
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
|
||
<input class="input input-sm table-search" data-target="group-table"
|
||
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
|
||
<label class="label m-0" for="lab-focus">Показать:</label>
|
||
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
|
||
<option value="">все лабораторные</option>
|
||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p class="text-sm text-muted mb-4">
|
||
Ячейка: «№» — вариант, «🛡» — защита (клик — задание и код), «↓» — сдача, «✎» — оценка.
|
||
Раздача вариантов — на странице лабораторной.
|
||
</p>
|
||
|
||
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
|
||
<div class="scroll-x">
|
||
<table class="matrix tablekit" id="group-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
|
||
<th th:each="lab : ${labs}" class="text-center lab-col" data-filter th:attr="data-lab=${lab.id}">
|
||
<div class="lab-col-head">
|
||
<div class="lab-head-row">
|
||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
|
||
<form class="inline-form" th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||
<input type="hidden" name="published"
|
||
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
|
||
<button class="badge badge-btn badge-sm" type="submit"
|
||
th:classappend="${publishedLabIds.contains(lab.id)} ? 'badge-ok' : 'badge-warn'"
|
||
th:title="${publishedLabIds.contains(lab.id) ? 'Скрыть оценки и комментарии от студентов' : 'Показать оценки и комментарии студентам'}"
|
||
th:text="${publishedLabIds.contains(lab.id) ? 'опубликовано' : 'опубликовать оценки'}">опубликовать оценки</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</th>
|
||
<th data-filter>Основа</th>
|
||
<th>Варианты</th>
|
||
<th data-filter>Пароль</th>
|
||
<th class="text-right" data-nosort>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr th:each="row : ${studentGrades}">
|
||
<td class="student-cell matrix-sticky">
|
||
<div class="font-medium" th:text="${row.fullName}">—</div>
|
||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||
</td>
|
||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
||
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
||
<div class="cell-chips">
|
||
<span class="cell-chip"
|
||
th:title="(${cell.variantNumber} ? 'Вариант ' + ${cell.variantNumber} : 'Вариант') + ' — ' + (${cell.variantManual} ? 'закреплён вручную (раздача его не тронет)' : 'выдан автоматически')">
|
||
№<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''"
|
||
th:text="${cell.variantNumber} ?: '—'">12</span>
|
||
</span>
|
||
<button class="cell-chip" type="button"
|
||
th:classappend="${cell.defenseStatus == null} ? 'chip-dim' : (${cell.defenseStatus == 'ready'} ? 'chip-ok' : 'chip-warn')"
|
||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus}"
|
||
onclick="openDefense(this)"
|
||
th:title="${cell.defenseStatus == null} ? 'Назначить задание на защиту' : 'Защита: задание и действия'"
|
||
th:text="${cell.defenseStatus == null} ? '🛡—' : (${cell.defenseStatus == 'waiting'} ? (${cell.defenseCode != null} ? '🛡🔑' : '🛡⌛') : (${cell.defenseStatus == 'started'} ? '🛡⏳' : '🛡✓'))">🛡—</button>
|
||
<a th:if="${cell.submissionId}" class="cell-chip chip-link"
|
||
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}"
|
||
title="Скачать последнее решение студента">↓<span class="font-mono" th:text="${cell.submissionSeq}">1</span></a>
|
||
<span th:unless="${cell.submissionId}" class="cell-chip chip-dim" title="Решений пока не сдавалось">↓—</span>
|
||
<button class="cell-chip" type="button"
|
||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"
|
||
onclick="openGradeEditor(this)"
|
||
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — открыть правку оценки и комментариев' : 'Поставить оценку и комментарии'"
|
||
>✎<span class="font-mono" th:if="${cell.score}" th:text="' ' + ${cell.score}"></span></button>
|
||
</div>
|
||
</td>
|
||
<td class="text-muted" th:text="${row.basis}">—</td>
|
||
<td class="text-sm">
|
||
<span th:if="${!#strings.isEmpty(row.variantsSummary)}" th:text="${row.variantsSummary}">—</span>
|
||
<span th:if="${#strings.isEmpty(row.variantsSummary)}" class="text-muted">не распределены</span>
|
||
</td>
|
||
<td><span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
|
||
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span></td>
|
||
<td class="text-right actions-cell">
|
||
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="btn btn-secondary btn-sm" type="submit"
|
||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить пароль</button>
|
||
</form>
|
||
<form class="inline-form"
|
||
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
||
method="post"
|
||
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="btn btn-danger btn-sm" type="submit">Отчислить из группы</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 5}">В группе нет студентов.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Диалог: оценка + комментарии -->
|
||
<dialog id="modal-grade" class="modal modal-lg">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="studentId" id="grade-student">
|
||
<input type="hidden" name="labId" id="grade-lab">
|
||
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
||
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
|
||
<div><label class="label" for="grade-score">Балл (0–5)</label>
|
||
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
|
||
<div id="cs-rows"></div>
|
||
<div class="flex gap-2">
|
||
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
|
||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Диалог: индивидуальное задание (защита) -->
|
||
<dialog id="modal-defense" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Индивидуальное задание</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-defense')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||
<div><label class="label" for="defense-student-select">Студент</label>
|
||
<select class="input" id="defense-student-select" name="studentId" required>
|
||
<option value="">— выберите студента —</option>
|
||
<option th:each="s : ${group.students}" th:value="${s.id}"
|
||
th:text="${s.fullName} + ' (' + ${s.studentNo} + ')'">—</option>
|
||
</select></div>
|
||
<div><label class="label" for="defense-lab-select">Лабораторная</label>
|
||
<select class="input" id="defense-lab-select" name="labId" required>
|
||
<option value="">— выберите лабораторную —</option>
|
||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
|
||
</select></div>
|
||
<div><label class="label" for="defense-md">Задание (markdown)</label>
|
||
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
|
||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||
</form>
|
||
<div id="defense-actions" class="mt-3" hidden>
|
||
<div class="flex flex-wrap gap-2">
|
||
<form class="inline-form" id="defense-issue-form" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="labId" id="defense-action-lab">
|
||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||
<button class="btn btn-secondary btn-sm" type="submit">выдать код</button>
|
||
</form>
|
||
<button class="btn btn-secondary btn-sm" type="button" onclick="showDefenseFromModal()">текст задания</button>
|
||
<form class="inline-form" id="defense-reset-form" method="post"
|
||
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="labId" id="defense-reset-lab">
|
||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||
<button class="btn btn-danger btn-sm" type="submit">начать заново</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Диалог просмотра текста задания -->
|
||
<dialog id="modal-defense-text" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="defense-text-title">Текст задания</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-text')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<pre id="defense-text-body" class="pre-wrap"></pre>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Диалог импорта CSV -->
|
||
<dialog id="modal-defense-import" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Импорт заданий (CSV)</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-import')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" th:action="@{/admin/defenses/import}" method="post" enctype="multipart/form-data">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||
<div><label class="label" for="cimport-lab">Лабораторная</label>
|
||
<select class="input" id="cimport-lab" name="labId">
|
||
<option value="">— определить из файла (3-й столбец) —</option>
|
||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
|
||
</select></div>
|
||
<div><label class="label" for="cimport-file">Файл CSV</label>
|
||
<input class="input" id="cimport-file" type="file" name="file" accept=".csv,.txt" required></div>
|
||
<p class="hint">
|
||
Разделитель <code class="code">;</code>, <code class="code">,</code> или таб; заголовок не обязателен; кавычки поддерживаются.<br>
|
||
<b>2 столбца</b> (нужна выбранная лаба): <code class="code">студенческий_номер;задание</code>.<br>
|
||
<b>3 столбца</b>: <code class="code">студенческий_номер;номер_ЛР;задание</code> — лабораторная берётся из файла.<br>
|
||
Переносы внутри задания — как <code class="code">\n</code>.
|
||
</p>
|
||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Диалог добавления студента -->
|
||
<dialog id="modal-add-student" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Добавить студента</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<div class="grid grid-2">
|
||
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
|
||
<input class="input" id="stNo" name="studentNo" required></div>
|
||
<div><label class="label" for="stBasis">Основа обучения</label>
|
||
<input class="input" id="stBasis" name="basis" placeholder="бюджет"></div>
|
||
</div>
|
||
<div><label class="label" for="stLast">Фамилия</label>
|
||
<input class="input" id="stLast" name="lastName" required></div>
|
||
<div class="grid grid-2">
|
||
<div><label class="label" for="stFirst">Имя</label>
|
||
<input class="input" id="stFirst" name="firstName"></div>
|
||
<div><label class="label" for="stMiddle">Отчество</label>
|
||
<input class="input" id="stMiddle" name="middleName"></div>
|
||
</div>
|
||
<p class="hint">Пароль будет сгенерирован автоматически.</p>
|
||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog id="modal-enroll-student" class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Добавить существующего студента</h2>
|
||
<button class="modal-close" type="button" onclick="closeModal('modal-enroll-student')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" id="enroll-form" method="post"
|
||
th:action="@{/admin/groups/{gid}/students/enroll(gid=${group.id})}">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<div><label class="label" for="enroll-select">Студент</label>
|
||
<select class="input" id="enroll-select" name="studentId" required>
|
||
<option value="">— выберите студента —</option>
|
||
<option th:each="c : ${candidates}" th:value="${c.id}"
|
||
th:text="${c.fullName} + ' (' + ${c.studentNo} + ')' + (${c.currentGroupName} ? ' — из ' + ${c.currentGroupName} : ' — без группы')">
|
||
</option>
|
||
</select></div>
|
||
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.
|
||
Отчисление не удаляет аккаунт, студент сможет войти и после перевода.</p>
|
||
<button class="btn btn-primary" type="submit">Зачислить</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<div th:if="${createdStudent}" class="card import-card toast">
|
||
<div class="card-body">
|
||
<h2 class="section-title">Студент добавлен</h2>
|
||
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
||
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
|
||
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/admin-group.js" defer></script>
|
||
</div>
|
||
</body>
|
||
</html>
|