349 lines
25 KiB
HTML
349 lines
25 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">
|
||
<div class="page-head">
|
||
<div>
|
||
<p class="kicker">Ведомость группы</p>
|
||
<h1 class="page-title" th:text="${group.name}">Группа</h1>
|
||
<p class="meta">
|
||
<span th:text="${#lists.size(group.students)}">0</span> студентов ·
|
||
<b th:text="${publishedLabsCount}">0</b>
|
||
<span th:with="p=${publishedLabsCount}"
|
||
th:text="${(p % 10 == 1 and p % 100 != 11) ? 'лабораторная' : ((p % 10 >= 2 and p % 10 <= 4 and (p % 100 < 12 or p % 100 > 14)) ? 'лабораторные' : 'лабораторных')}">лабораторных</span>
|
||
с открытыми оценками
|
||
</p>
|
||
</div>
|
||
<div class="actions">
|
||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
|
||
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<input class="input table-search w-260" data-target="group-table"
|
||
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
|
||
<select class="input input-lab-focus" id="lab-focus">
|
||
<option value="">все лабораторные</option>
|
||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
|
||
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></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" th:attr="data-lab=${lab.id}">
|
||
<div class="lab-col-head">
|
||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
|
||
<span class="lab-col-title" th:text="${lab.title}">—</span>
|
||
<div class="lab-head-actions">
|
||
<button class="btn btn-secondary btn-sm p-2" type="button"
|
||
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
|
||
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
|
||
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
|
||
</button>
|
||
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
|
||
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 class="js-pub-published" type="hidden" name="published"
|
||
th:value="${all} ? 'false' : 'true'">
|
||
<button class="btn btn-secondary btn-sm p-2 js-pub-eye" type="submit"
|
||
th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
|
||
th:classappend="${all} ? 'is-all' : (${pc > 0} ? 'is-partial' : 'is-empty')"
|
||
th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
|
||
<i class="eye-open ico bi bi-eye" aria-hidden="true"></i>
|
||
<i class="eye-closed ico bi bi-eye-slash" aria-hidden="true"></i>
|
||
<span class="mini-count js-pub-count" th:classappend="(${all} or ${pc == 0}) ? 'is-hidden' : ''" th:text="${pc}"></span>
|
||
</button>
|
||
</form>
|
||
<a class="btn btn-secondary btn-sm p-2"
|
||
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}"
|
||
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
|
||
</div>
|
||
</div>
|
||
</th>
|
||
<th data-nosort>Пароль</th>
|
||
<th class="text-right" data-nosort>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr th:each="row : ${studentGrades}">
|
||
<td class="student-cell matrix-sticky">
|
||
<div class="student-name" 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-stack">
|
||
<div class="cell-main">
|
||
<button class="variant-btn" type="button"
|
||
th:classappend="${cell.variantManual} ? 'manual' : ''"
|
||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}" data-action="variant"
|
||
title="Назначить вариант вручную или выдать случайный">
|
||
<span th:if="${cell.variantNumber}" th:text="'В-' + ${cell.variantNumber}">В-1</span>
|
||
<span th:if="${cell.variantNumber == null}" class="cell-none">выдать</span>
|
||
</button>
|
||
<button class="score-link" type="button"
|
||
th:classappend="${cell.score == null} ? 'none' : ''"
|
||
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}" data-action="grade"
|
||
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Оценки нет — выставить балл и комментарии'">
|
||
<span th:text="${cell.score} ?: '—'">—</span>
|
||
</button>
|
||
<input type="checkbox" class="pub-toggle"
|
||
th:checked="${cell.published}"
|
||
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
|
||
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
|
||
</div>
|
||
<div class="cell-sub">
|
||
<a th:if="${cell.submissionId}" class="sub-link"
|
||
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">сдача ↓ <span th:text="${cell.submissionSeq}">1</span></a>
|
||
<span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
|
||
<button class="cell-btn st js-defense-cell" type="button"
|
||
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : '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},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode},data-reason=${cell.defenseReadyReason}" data-action="defense"
|
||
th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
|
||
<span class="cell-dim">защита:</span>
|
||
<span class="js-def-label"
|
||
th:text="${cell.defenseStatus == null ? '—' : (cell.defenseStatus == 'waiting'
|
||
? (cell.defenseCode != null ? 'код ' + cell.defenseCode : 'ожидает')
|
||
: (cell.defenseStatus == 'ready' ? 'готова' : ''))}">—</span>
|
||
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer js-def-timer" th:data-seconds="${cell.defenseSecondsLeft}">идёт</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="text-center">
|
||
<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">
|
||
<div class="actions-col">
|
||
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="btn btn-secondary btn-sm" type="submit"
|
||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
|
||
</form>
|
||
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
||
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Диалог: оценка + комментарии -->
|
||
<dialog id="modal-grade" class="dlg dlg-lg">
|
||
<div class="dlg-header">
|
||
<h2 class="dlg-title" id="grade-title">Оценка и комментарии</h2>
|
||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||
</div>
|
||
<div class="dlg-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" type="button" data-action="comment-add">+ комментарий</button>
|
||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Диалог: индивидуальное задание (защита) -->
|
||
<!-- Диалог: назначение варианта -->
|
||
<dialog id="modal-variant" class="dlg dlg-lg">
|
||
<div class="dlg-header">
|
||
<h2 class="dlg-title">Назначить вариант</h2>
|
||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||
</div>
|
||
<div class="dlg-body form-stack">
|
||
<p class="text-sm text-muted" id="variant-who">—</p>
|
||
<div><label class="label" for="variant-level">Уровень</label>
|
||
<select class="input" id="variant-level"></select></div>
|
||
<div><label class="label" for="variant-variant">Вариант</label>
|
||
<select class="input" id="variant-variant"></select></div>
|
||
<div id="variant-task-wrap" hidden>
|
||
<label class="label">Текст задания</label>
|
||
<div id="variant-task" class="md-body"></div>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<form class="inline-form" id="variant-random-form" method="post"
|
||
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="studentId" id="variant-random-student">
|
||
<input type="hidden" name="labId" id="variant-random-lab">
|
||
<input type="hidden" name="labLevelId" id="variant-random-level">
|
||
<button class="btn btn-primary" type="submit">Выдать случайный из уровня</button>
|
||
</form>
|
||
<form class="inline-form" id="variant-pin-form" method="post"
|
||
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<input type="hidden" name="studentId" id="variant-pin-student">
|
||
<input type="hidden" name="labId" id="variant-pin-lab">
|
||
<input type="hidden" name="variantId" id="variant-pin-id">
|
||
<button class="btn btn-secondary" type="submit">Закрепить выбранный</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog id="modal-defense" 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" 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>
|
||
</form>
|
||
<p id="defense-status" class="text-sm text-muted mt-4"></p>
|
||
<div class="flex flex-wrap gap-2 defense-footer">
|
||
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
|
||
<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" type="submit">Выдать код</button>
|
||
</form>
|
||
<form class="inline-form" id="defense-reset-form" method="post" data-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" type="submit">Начать заново</button>
|
||
</form>
|
||
</div>
|
||
<button class="btn btn-primary" type="submit" form="defense-form">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
|
||
|
||
|
||
<!-- Диалог добавления студента -->
|
||
<dialog id="modal-add-student" 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" 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-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
|
||
</form>
|
||
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<div class="row g-3">
|
||
<div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label>
|
||
<input class="input" id="stNo" name="studentNo" required></div>
|
||
<div class="col-md-6"><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="row g-3">
|
||
<div class="col-md-6"><label class="label" for="stFirst">Имя</label>
|
||
<input class="input" id="stFirst" name="firstName"></div>
|
||
<div class="col-md-6"><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>
|
||
|
||
|
||
|
||
<div th:if="${createdStudent}" class="card import-card">
|
||
<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>
|
||
|
||
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
|
||
<div th:if="${importResult}" class="card import-card">
|
||
<div class="card-body">
|
||
<h2 class="section-title">
|
||
Группа <span th:text="${importResult.groupName}">—</span> импортирована
|
||
</h2>
|
||
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.added)}">
|
||
Новых студентов: <b th:text="${#lists.size(importResult.added)}">0</b> — логины и одноразовые пароли:
|
||
</p>
|
||
<table th:if="${!#lists.isEmpty(importResult.added)}">
|
||
<thead><tr><th>Студент</th><th>Логин</th><th>Пароль</th></tr></thead>
|
||
<tbody>
|
||
<tr th:each="c : ${importResult.added}">
|
||
<td th:text="${c.fullName}">—</td>
|
||
<td class="font-mono" th:text="${c.studentNo}">—</td>
|
||
<td class="font-mono font-semibold" th:text="${c.password}">—</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
|
||
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
|
||
</p>
|
||
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Большая модалка «Выдача вариантов»: содержимое подгружается фрагментом по лабе -->
|
||
<dialog id="modal-lab-assign" class="dlg dlg-lg">
|
||
<div class="dlg-header">
|
||
<h2 class="dlg-title" id="assign-modal-title">Выдача вариантов</h2>
|
||
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
|
||
</div>
|
||
<div class="dlg-body" id="assign-modal-body"></div>
|
||
</dialog>
|
||
|
||
<script th:src="@{/js/admin-group.js(v='32')}" defer></script>
|
||
</div>
|
||
</body>
|
||
</html>
|