Phase 7: deployment, data import, UI hardening and security fixes
Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers
Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
selected students (checkboxes)
Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
from group page; temporary-password lock (no navigation until changed) and
persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
chapters tree+assets); duplicate lab numbers rejected; variant ordering
and date defaults honored
Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
@@ -4,14 +4,18 @@
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
|
||||
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title" th:text="${group.name}">Группа</h1>
|
||||
<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 gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
|
||||
<button class="btn btn-secondary" type="button"
|
||||
th:disabled="${#lists.isEmpty(candidates)}"
|
||||
onclick="openModal('modal-enroll-student')">Добавить существующего</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>
|
||||
@@ -19,73 +23,141 @@
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
<div th:if="${resetPassword}" class="alert alert-warning">
|
||||
Новый пароль: <code class="code" th:text="${resetPassword}">password</code>
|
||||
Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code>
|
||||
</div>
|
||||
|
||||
<div th:if="${importResult}" class="card import-card mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="section-title">Импорт: <span th:text="${importResult.groupName}">группа</span></h2>
|
||||
<p class="text-sm text-muted">
|
||||
Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>,
|
||||
обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.
|
||||
</p>
|
||||
<div th:if="${not #lists.isEmpty(importResult.added)}" class="mt-4 overflow-x-auto">
|
||||
<p class="text-sm alert-warning mini-alert">Сохраните пароли — они показываются для выдачи студентам.</p>
|
||||
<table>
|
||||
<thead><tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="credential : ${importResult.added}">
|
||||
<td th:text="${credential.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${credential.studentNo}">—</td>
|
||||
<td class="font-mono font-semibold" th:text="${credential.password}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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)}">
|
||||
<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 mb-6" th:if="${!#lists.isEmpty(labs)}">
|
||||
<div class="scroll-x">
|
||||
<table class="matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="matrix-sticky">Студент</th>
|
||||
<th th:each="lab : ${labs}" class="text-center lab-col" 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>
|
||||
</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}">
|
||||
<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>
|
||||
<span th:if="${cell.defenseStatus == null}" class="cell-chip chip-dim" title="Защита не назначена">🛡—</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}" class="cell-chip chip-warn" title="Задание для защиты выдано, ещё без кода">🛡⌛</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}" class="cell-chip chip-warn" title="Защита: код студенту выдан, ждёт входа">🛡🔑</span>
|
||||
<span th:if="${cell.defenseStatus == 'started'}" class="cell-chip chip-warn" title="Защита: студент пишет работу">🛡⏳</span>
|
||||
<span th:if="${cell.defenseStatus == 'ready'}" class="cell-chip chip-ok" title="Защита: работа сохранена, можно принимать">🛡✓</span>
|
||||
<a th:if="${cell.submissionId}" class="cell-chip chip-link"
|
||||
th:href="'/admin/submissions/' + ${cell.submissionId} + '/download'"
|
||||
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>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 1}">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Баллы за лабораторные</h2>
|
||||
<p class="text-sm text-muted mb-4">Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.</p>
|
||||
<div class="card card-flush mb-6 overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Студент</th>
|
||||
<th class="text-center" th:each="lab : ${labs}">
|
||||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
|
||||
</th>
|
||||
<th class="text-center">Средний</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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)}">
|
||||
<label class="label m-0" for="defense-lab-filter">Лабораторная:</label>
|
||||
<select class="input input-sm" id="defense-lab-filter" onchange="filterDefense()">
|
||||
<option value="">Все</option>
|
||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openDefenseForm(this)">Назначить задание</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-flush mb-8">
|
||||
<table id="defense-table">
|
||||
<thead><tr><th>Студент</th><th>ЛР</th><th>Задание</th><th>Статус</th><th class="text-right">Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${studentGrades}">
|
||||
<td th:text="${row.fullName}">—</td>
|
||||
<td th:each="score, iter : ${row.scores}">
|
||||
<div>
|
||||
<div class="score-cell-root">
|
||||
<form class="inline-form">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
|
||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${labs[iter.index].id},data-lab-name='ЛР ' + ${labs[iter.index].number},data-score=${score},data-comments=${row.commentsJson},data-ci=${iter.index}">✎</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<span th:each="c : ${row.comments[iter.index]}" class="chip"
|
||||
th:classappend="${c.second == 'positive'} ? chip-pos : (${c.second == 'negative'} ? chip-neg : chip-neu)"
|
||||
th:text="${c.first}">коммент</span>
|
||||
</div>
|
||||
</div>
|
||||
<tr th:each="d : ${defenseRows}" th:attr="data-lab=${d.labId}">
|
||||
<td>
|
||||
<div th:text="${d.fullName}">—</div>
|
||||
<div class="text-sm text-muted font-mono" th:text="${d.studentNo}">—</div>
|
||||
</td>
|
||||
<td class="text-center font-semibold">
|
||||
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
|
||||
<span th:if="${row.average == null}" class="text-muted">—</span>
|
||||
<td class="font-mono" th:text="|ЛР ${d.labNumber}|">—</td>
|
||||
<td class="text-sm text-muted" th:text="${#strings.abbreviate(d.assignmentMd, 60)}">—</td>
|
||||
<td>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code == null}">ждёт кода</span>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code != null}">код выдан</span>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'started'}">пишет (<span th:text="${d.secondsLeft}"></span> c)</span>
|
||||
<span class="badge badge-ok" th:if="${d.status == 'ready'}">готов</span>
|
||||
</td>
|
||||
<td class="text-right defense-cell-actions">
|
||||
<form class="inline-form" th:if="${d.sessionId != null}"
|
||||
th:action="'/admin/defenses/' + ${d.sessionId} + '/issue-code'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${d.labId}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"
|
||||
th:text="${d.status == 'waiting' and d.code != null ? 'новый код' : 'выдать код'}">выдать код</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-student=${d.studentId},data-student-name=${d.fullName},data-lab=${d.labId},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
|
||||
onclick="openDefenseForm(this)">изменить</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-student-name=${d.fullName},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
|
||||
onclick="showDefenseText(this)">текст</button>
|
||||
<form class="inline-form" th:if="${d.status == 'ready'}"
|
||||
th:action="'/admin/defenses/' + ${d.sessionId} + '/reset'" method="post"
|
||||
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${d.labId}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">доп. задание: начать заново</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(group.students)}">
|
||||
<td class="empty" th:colspan="${#lists.size(labs) + 2}">В группе нет студентов.</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(defenseRows)}"><td colspan="5" class="empty">Заданий для защиты пока нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -93,27 +165,182 @@
|
||||
<h2 class="section-title">Студенты</h2>
|
||||
<div class="card card-flush">
|
||||
<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><th>Пароль</th><th class="text-right">Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="student, iter : ${group.students}">
|
||||
<tr th:each="row, iter : ${studentGrades}">
|
||||
<td class="text-muted" th:text="${iter.count}">1</td>
|
||||
<td th:text="${student.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${student.studentNo}">—</td>
|
||||
<td class="text-muted" th:text="${student.basis}">—</td>
|
||||
<td><span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span>
|
||||
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
|
||||
<td class="text-center">
|
||||
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Сбросить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<td th:text="${row.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${row.studentNo}">—</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(group.students)}"><td colspan="6" class="empty">В группе пока нет студентов.</td></tr>
|
||||
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="7" class="empty">В группе пока нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Диалог: оценка + комментарии -->
|
||||
<dialog id="modal-grade" class="modal">
|
||||
<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>
|
||||
</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>
|
||||
@@ -121,128 +348,10 @@
|
||||
<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>
|
||||
<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-grade" class="modal">
|
||||
<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"
|
||||
onsubmit="return csSerialize()">
|
||||
<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">
|
||||
<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>
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<datalist id="cs-list"></datalist>
|
||||
<script>
|
||||
var CS_TEMPLATES = [];
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
CS_TEMPLATES = /*[[${commentOptions}]]*/ [];
|
||||
|
||||
function openGradeEditor(btn) {
|
||||
var all = JSON.parse(btn.getAttribute('data-comments') || '[]');
|
||||
var list = all[parseInt(btn.getAttribute('data-ci') || '0', 10)] || [];
|
||||
document.getElementById('grade-student').value = btn.getAttribute('data-student');
|
||||
document.getElementById('grade-lab').value = btn.getAttribute('data-lab');
|
||||
document.getElementById('grade-who').value = btn.getAttribute('data-student-name');
|
||||
document.getElementById('grade-title').textContent = btn.getAttribute('data-lab-name') + ' — ' + btn.getAttribute('data-student-name');
|
||||
var sc = document.getElementById('grade-score');
|
||||
sc.value = btn.getAttribute('data-score') || '';
|
||||
var box = document.getElementById('cs-rows');
|
||||
box.innerHTML = '';
|
||||
list.forEach(function (c) { csRow(c.text, c.tone); });
|
||||
if (!list.length) csRow('', 'neutral');
|
||||
openModal('modal-grade');
|
||||
}
|
||||
|
||||
function csRow(text, tone) {
|
||||
var box = document.getElementById('cs-rows');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'cs-row';
|
||||
var inp = document.createElement('input');
|
||||
inp.className = 'input cs-text'; inp.placeholder = 'Комментарий (подскажем частые)';
|
||||
inp.setAttribute('list', 'cs-list');
|
||||
inp.value = text;
|
||||
inp.addEventListener('input', csFilter);
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'input cs-tone'
|
||||
[['positive', '+'], ['neutral', 'o'], ['negative', '−']].forEach(function (o) {
|
||||
var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op);
|
||||
});
|
||||
sel.value = tone || 'neutral';
|
||||
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm cs-del'; rm.textContent = '✕';
|
||||
rm.addEventListener('click', function () { row.remove(); });
|
||||
row.appendChild(inp); row.appendChild(sel); row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
}
|
||||
|
||||
function csFilter(ta) {
|
||||
var list = document.getElementById('cs-list');
|
||||
list.innerHTML = '';
|
||||
var q = (ta.target.value || '').toLowerCase().trim();
|
||||
CS_TEMPLATES
|
||||
.filter(function (t) { return !q || t.text.toLowerCase().indexOf(q) !== -1; })
|
||||
.slice(0, 8)
|
||||
.forEach(function (t) {
|
||||
var op = document.createElement('option');
|
||||
op.value = t.text;
|
||||
list.appendChild(op);
|
||||
});
|
||||
}
|
||||
|
||||
function csSerialize() {
|
||||
var out = [];
|
||||
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
|
||||
var text = r.querySelector('.cs-text').value.trim();
|
||||
if (text) out.push({ text: text, tone: r.querySelector('.cs-tone').value });
|
||||
});
|
||||
document.getElementById('grade-comments').value = JSON.stringify(out);
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script src="/js/admin-group.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,85 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="|Правка: ${lab.title} — LMS|">Правка лабы</title></head>
|
||||
<head><title th:text="|Настройка: ${lab.title} — LMS|">Настройка лабы</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
|
||||
<h1 class="page-title mt-4">Правка лабораторной</h1>
|
||||
<h1 class="page-title mt-4">Настройка лабораторной</h1>
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
|
||||
<form class="card form-card form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
|
||||
<div><label class="label" for="title">Название</label>
|
||||
<input class="input" id="title" name="title" th:value="${lab.title}" required></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="deadlineAt">Срок сдачи</label>
|
||||
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local"
|
||||
th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="defenseAt">Дата защиты</label>
|
||||
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local"
|
||||
th:value="${defenseLocal}"></div>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="pointsMax">Максимум баллов</label>
|
||||
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0"
|
||||
th:value="${lab.pointsMax}" required></div>
|
||||
<div><label class="label" for="status">Статус</label>
|
||||
<select class="input" id="status" name="status">
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published</option>
|
||||
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
|
||||
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14"
|
||||
th:text="${lab.descriptionMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<form class="form-stack" method="post" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
|
||||
<h2 class="section-title mt-8">Варианты</h2>
|
||||
<div th:each="level : ${lab.levels}" class="mb-6">
|
||||
<h3 class="group-title" th:text="${level.title}">Уровень</h3>
|
||||
<div th:each="v : ${level.variants}" class="card form-card form-stack mb-4">
|
||||
<form th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="variantId" th:value="${v.id}">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<h2 class="section-title">Параметры</h2>
|
||||
<div class="card form-card form-stack">
|
||||
<div><label class="label" for="title">Название</label>
|
||||
<input class="input" id="title" name="title" th:value="${lab.title}" required></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="deadlineAt">Срок сдачи (сейчас: <span th:text="${deadlineMsk}" th:if="${deadlineMsk}">—</span> МСК)</label>
|
||||
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="defenseAt">Дата защиты (сейчас: <span th:text="${defenseMsk}" th:if="${defenseMsk}">—</span> МСК)</label>
|
||||
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="pointsMax">Максимум баллов</label>
|
||||
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
|
||||
<div><label class="label" for="status">Статус</label>
|
||||
<select class="input" id="status" name="status">
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft (черновик)</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published (опубликована)</option>
|
||||
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
|
||||
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14" th:text="${lab.descriptionMd}"></textarea></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Уровни</h2>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th class="th-w-sm">Код</th><th>Название</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="level : ${lab.levels}">
|
||||
<td class="font-mono" th:text="${level.code}">basic</td>
|
||||
<td>
|
||||
<input type="hidden" name="levelIds" th:value="${level.id}">
|
||||
<input class="input" name="levelTitles" th:value="${level.title}" required>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-danger btn-sm" type="submit" name="action"
|
||||
th:value="|delete-level-${level.id}|"
|
||||
onclick="return confirm('Удалить уровень? Варианты на нём должны быть пустыми.')">
|
||||
удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровней нет — добавьте ниже.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="card card-body mb-4">
|
||||
<summary class="acc-head text-sm">+ Новый уровень</summary>
|
||||
<div class="grid grid-2 mt-4">
|
||||
<div><label class="label" for="nlCode">Код (латиницей)</label>
|
||||
<input class="input" id="nlCode" name="newLevelCode" placeholder="pro"></div>
|
||||
<div><label class="label" for="nlTitle">Название</label>
|
||||
<input class="input" id="nlTitle" name="newLevelTitle" placeholder="Профессиональный"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2 class="section-title">Варианты</h2>
|
||||
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Сначала создайте уровень — затем варианты.</div>
|
||||
|
||||
<div th:each="level : ${lab.levels}" class="mb-4">
|
||||
<h3 class="group-title" th:text="|${level.title} (${#lists.size(level.variants)})|">Уровень</h3>
|
||||
<div th:each="v : ${level.variants}" class="card form-card mb-4">
|
||||
<input type="hidden" name="variantIds" th:value="${v.id}">
|
||||
<input type="hidden" name="variantLevelIds" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
|
||||
<div><label class="label">Номер</label>
|
||||
<input class="input" name="variantNumbers" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="variantTitles" th:value="${v.title}"></div>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="6" th:text="${v.statementMd}"></textarea></div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить вариант?')">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<textarea class="input" name="variantStatements" rows="6" th:text="${v.statementMd}"></textarea></div>
|
||||
<button class="btn btn-danger btn-sm" type="submit" name="action"
|
||||
th:value="|delete-variant-${v.id}|"
|
||||
onclick="return confirm('Удалить вариант?')">Удалить вариант</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title mt-8">Добавить вариант</h2>
|
||||
<form class="card form-card form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="newLevel">Уровень</label>
|
||||
<select class="input" id="newLevel" name="labLevelId">
|
||||
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="newNumber">Номер</label>
|
||||
<input class="input" id="newNumber" name="number" required></div>
|
||||
<details class="card card-body mb-4" th:if="${!#lists.isEmpty(lab.levels)}">
|
||||
<summary class="acc-head text-sm">+ Добавить вариант</summary>
|
||||
<div class="grid grid-2 mt-4">
|
||||
<div><label class="label" for="avLevel">Уровень</label>
|
||||
<select class="input" id="avLevel" name="addLevelId">
|
||||
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="avNumber">Номер</label>
|
||||
<input class="input" id="avNumber" name="addNumber" placeholder="21"></div>
|
||||
</div>
|
||||
<div><label class="label" for="avTitle">Название</label>
|
||||
<input class="input" id="avTitle" name="addTitle"></div>
|
||||
<div><label class="label" for="avStatement">Условие (markdown)</label>
|
||||
<textarea class="input" id="avStatement" name="addStatementMd" rows="6"></textarea></div>
|
||||
</details>
|
||||
|
||||
<div class="flex gap-2 mb-8">
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Сохранить всё</button>
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="add-level">Добавить уровень</button>
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="add-variant">Добавить вариант</button>
|
||||
</div>
|
||||
<div><label class="label" for="newTitle">Название</label>
|
||||
<input class="input" id="newTitle" name="title"></div>
|
||||
<div><label class="label" for="newStatement">Условие (markdown)</label>
|
||||
<textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -8,309 +8,189 @@
|
||||
<div>
|
||||
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
||||
<p class="text-sm text-muted">
|
||||
Статус: <form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||
Статус:
|
||||
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
|
||||
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</button>
|
||||
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
|
||||
th:text="${lab.status}">draft</button>
|
||||
</form>
|
||||
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
||||
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
||||
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
||||
<span th:if="${assignedCount > 0}" class="chip chip-neu">Вариантов назначено: <span th:text="${assignedCount}"></span></span>
|
||||
<span th:if="${totalVariants > 0}" class="chip chip-neu">Занято вариантов: <span th:text="${occupiedVariants}"></span> из <span th:text="${totalVariants}"></span></span>
|
||||
<span th:if="${totalVariants > 0}" class="chip chip-pos">Свободно: <span th:text="${freeVariants}"></span></span>
|
||||
<span th:if="${submissionCount > 0}" class="chip chip-pos">Сдано: <span th:text="${submissionCount}"></span></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-meta')">Править описание</button>
|
||||
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</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>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-distribute')">Распределить</button>
|
||||
</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>
|
||||
|
||||
<h2 class="section-title">Условие</h2>
|
||||
<h2 class="section-title">Условие лабораторной</h2>
|
||||
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="section-title">Уровни и варианты</h2>
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-level')">+ Уровень</button>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="section-title m-0">Назначения студентов</h2>
|
||||
<p class="text-sm text-muted m-0">
|
||||
Смена уровня сразу подбирает вариант; конкретный вариант можно закрепить вручную.
|
||||
</p>
|
||||
</div>
|
||||
<div th:if="${#lists.isEmpty(assignmentGroups)}" class="card card-body mb-6 text-muted">Нет студентов для назначения.</div>
|
||||
|
||||
<div class="card card-body mb-4" th:if="${!#lists.isEmpty(assignmentGroups)}">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="tone-label"><input type="checkbox" id="batch-all"
|
||||
onchange="toggleAllStudents(this)"> Выбрать всех</label>
|
||||
<form id="batch-level-form" class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/batch-level(id=${lab.id})}"
|
||||
onsubmit="return collectPicked(this)">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<select name="labLevelId" class="input input-sm" th:disabled="${#lists.isEmpty(lab.levels)}">
|
||||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}" th:text="${lv.title}">Уровень</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Назначить уровень выбранным</button>
|
||||
</form>
|
||||
<form id="batch-variant-form" class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/batch-variant(id=${lab.id})}"
|
||||
onsubmit="return collectPicked(this)">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<select name="variantId" class="input input-sm">
|
||||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||||
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Закрепить вариант выбранным</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:each="g : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
|
||||
<div class="card-body">
|
||||
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||||
<div class="scroll-x">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th class="th-w-sm">Выбор</th><th>Студент</th><th class="th-w-md">Уровень</th><th class="th-w-md">Вариант</th>
|
||||
<th class="th-w-sm">Источник</th><th>Сдача</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${g.students}">
|
||||
<td><input type="checkbox" class="pick-student" th:value="${s.studentId}"></td>
|
||||
<td>
|
||||
<div th:text="${s.fullName}">—</div>
|
||||
<div class="text-sm text-muted font-mono" th:text="${s.studentNo}">—</div>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/set-level(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<select name="labLevelId" class="input input-sm" onchange="this.form.submit()"
|
||||
th:disabled="${#lists.isEmpty(lab.levels)}">
|
||||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
|
||||
th:selected="${lv.id == s.levelId}" th:text="${lv.title}">Уровень</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/assign(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<select name="variantId" class="input input-sm pick-variant" onchange="confirmOccupied(this)">
|
||||
<option value="">— авто —</option>
|
||||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||||
th:attr="data-taken=${variantUsage[v.id] ?: 0}"
|
||||
th:selected="${v.id == s.variantId}"
|
||||
th:text="${(variantUsage[v.id] ?: 0) > 1 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-ok' : 'badge-warn'"
|
||||
th:text="${s.source}">авто</span>
|
||||
</td>
|
||||
<td>
|
||||
<a th:if="${s.submissionId}" class="cell-link"
|
||||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'">
|
||||
попытка <span th:text="${s.submissionSeq}">1</span> ↓</a>
|
||||
<span th:unless="${s.submissionId}" class="text-muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Уровни и варианты</h2>
|
||||
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Уровней пока нет — добавьте на экране настройки.</div>
|
||||
<div th:each="level : ${lab.levels}" class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<h3 class="group-title">
|
||||
<span class="font-mono text-muted" th:text="${level.code}">basic</span> —
|
||||
<span th:text="${level.title}">Уровень</span>
|
||||
(<span th:text="${#lists.size(level.variants)}">0</span> вар.)
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="openModalData(this)">Переименовать</button>
|
||||
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить уровень без вариантов?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<button class="btn btn-primary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="openModalData(this)">+ Вариант</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-2">
|
||||
<h3 class="section-title" th:text="${level.title}">Уровень</h3>
|
||||
<span class="text-sm text-muted" th:text="|Вариантов: ${#lists.size(level.variants)}|">0</span>
|
||||
</div>
|
||||
<div class="scroll-x">
|
||||
<table>
|
||||
<thead><tr><th class="th-w-sm">№</th><th class="th-w-md">Название</th><th>Условие</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="v : ${level.variants}">
|
||||
<td class="font-mono" th:text="${v.number}">1</td>
|
||||
<td th:text="${v.title}">—</td>
|
||||
<td class="text-muted preview" th:text="${#strings.abbreviate(v.statementMd, 70)}">—</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="3" class="empty">Вариантов нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="v : ${level.variants}">
|
||||
<td class="font-mono" th:text="${v.number}">—</td>
|
||||
<td th:text="${v.title}">—</td>
|
||||
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="openModalData(this)">Править</button>
|
||||
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить вариант?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Студенты и варианты</h2>
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<p class="text-sm text-muted">Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.</p>
|
||||
<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>
|
||||
<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><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>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</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>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</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>
|
||||
<td><a th:if="${s.submissionId}" class="badge badge-ok"
|
||||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
|
||||
th:text="|попытка ${s.submissionSeq}|">↓</a>
|
||||
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
|
||||
<td>
|
||||
<span th:if="${s.defenseStatus == null}" class="text-muted">не назначено</span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode != null}" class="badge badge-warn">код <span class="font-mono" th:text="${s.defenseCode}"></span></span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode == null}" class="badge badge-warn">ждёт задания</span>
|
||||
<span th:if="${s.defenseStatus == 'started'}" class="badge badge-warn">на защите (<span th:text="${s.defenseSecondsLeft}"></span>с)</span>
|
||||
<span th:if="${s.defenseStatus == 'ready'}" class="badge badge-ok">готов к защите</span>
|
||||
<form class="inline-form" th:if="${s.defenseSessionId != null}" th:action="'/admin/defenses/' + ${s.defenseSessionId} + '/issue-code'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus != 'ready'}">выдать код</button>
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus == 'ready'}">заново</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="8" class="empty">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 class="section-title mt-8">Индивидуальные задания для защиты</h2>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-defense-assign')">Выдать / изменить задание</button>
|
||||
<form class="inline-form" th:action="'/admin/labs/' + ${lab.id} + '/defense/import'" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm" type="file" name="file" accept=".csv" required>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Импорт CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.</p>
|
||||
|
||||
<dialog id="modal-defense-assign" class="modal">
|
||||
<dialog id="modal-distribute" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Индивидуальное задание</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-assign')">✕</button>
|
||||
<h2 class="modal-title">Распределение вариантов</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-distribute')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="'/admin/labs/' + ${lab.id} + '/defense/assign'" method="post">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<div><label class="label">Студент</label>
|
||||
<select class="input" id="defense-student" name="studentId" required onchange="fillAssignment(this)">
|
||||
<option value="">— выберите студента —</option>
|
||||
<optgroup th:each="g : ${groups}" th:label="${g.groupName}">
|
||||
<option th:each="s : ${g.students}" th:value="${s.studentId}"
|
||||
th:attr="data-md=${s.adminAssignmentMd}"
|
||||
th:text="${s.fullName}">—</option>
|
||||
</optgroup>
|
||||
</select></div>
|
||||
<div><label class="label">Задание (markdown)</label>
|
||||
<textarea class="input" id="defense-md" name="assignmentMd" rows="8" required></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="modal-meta" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка лабораторной</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
|
||||
<div><label class="label" for="metaTitle">Название</label>
|
||||
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="metaDeadline">Срок сдачи</label>
|
||||
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="metaDefense">Дата защиты</label>
|
||||
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="metaPoints">Максимум баллов</label>
|
||||
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
|
||||
<div><label class="label" for="metaStatus">Статус</label>
|
||||
<select class="input" id="metaStatus" name="status">
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published</option>
|
||||
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label class="label" for="metaDesc">Условие (markdown)</label>
|
||||
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modal-add-level" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Добавить уровень</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="lvlCode">Код</label>
|
||||
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
|
||||
<div><label class="label" for="lvlTitle">Название</label>
|
||||
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Переименовать уровень</h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="levelId" th:value="${level.id}">
|
||||
<input type="hidden" name="code" th:value="${level.code}">
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="title" th:value="${level.title}" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title"></div>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="8"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<th:block th:each="level : ${lab.levels}">
|
||||
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка варианта</h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="variantId" th:value="${v.id}">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
|
||||
<p class="text-sm">
|
||||
Студенты выбранных групп раздаются одной общей очерёдностью. Каждый получает
|
||||
вариант <b>со своего уровня</b> (без уровня — с первого уровня лабораторной):
|
||||
пока внутри уровня есть не выданные никому варианты — они выдаются без повторов,
|
||||
затем вариант берётся из наименее занятых. Ручные закрепления сохраняются всегда.
|
||||
</p>
|
||||
<div th:if="${!#lists.isEmpty(groups)}">
|
||||
<label class="label">Группы (ничего не выбрано — все группы)</label>
|
||||
<div class="check-list">
|
||||
<label class="tone-label" th:each="g : ${groups}">
|
||||
<input type="checkbox" name="groupIds" th:value="${g.id}">
|
||||
<span th:text="${g.name}">—</span>
|
||||
</label>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
</div>
|
||||
<label class="tone-label">
|
||||
<input type="checkbox" name="force" value="true">
|
||||
Пересоздать заново (сбросить авто-назначения у выбранных; вручную закреплённые останутся)
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Распределить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function fillAssignment(sel) { document.getElementById("defense-md").value = sel.options[sel.selectedIndex].getAttribute("data-md") || ""; }
|
||||
function filterGroups(name) {
|
||||
document.querySelectorAll('.group-section').forEach(function (el) {
|
||||
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/js/admin-lab.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<div id="content">
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<h1 class="page-title">Лабораторные</h1>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
|
||||
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
|
||||
@@ -36,6 +39,29 @@
|
||||
</div>
|
||||
|
||||
|
||||
<dialog id="modal-import-lab" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Импорт лабораторной</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-import-lab')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
|
||||
<div><label class="label" for="labFile">Файл</label>
|
||||
<input class="input" id="labFile" type="file" name="file" accept=".zip" required></div>
|
||||
<p class="hint">
|
||||
Формат — ZIP: <code class="code">lab.yaml</code> + <code class="code">README.md</code> +
|
||||
<code class="code">variants/*.md</code> (front-matter: <code class="code">level</code>,
|
||||
<code class="code">number</code>, <code class="code">title</code>) +
|
||||
<code class="code">assets/</code> (картинки). Пример структуры — в
|
||||
<code class="code">deploy/IMPORT.md</code>.
|
||||
</p>
|
||||
<p class="hint">Даты сдачи и защиты после импорта задаются на карточке лабораторной.</p>
|
||||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modal-create-lab" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Новая лабораторная</h2>
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
<div id="content">
|
||||
<div class="container-sm">
|
||||
<h1 class="page-title">Смена пароля</h1>
|
||||
<p class="text-sm text-muted mb-4">При первом входе пароль нужно сменить.</p>
|
||||
<p class="text-sm text-muted mb-4" th:if="${currentUser != null and currentUser.mustChangePassword}">
|
||||
Это временный пароль. Пока вы его не смените, другие страницы недоступны.
|
||||
Смена потребуется один раз — после этого система больше не попросит.
|
||||
</p>
|
||||
<p class="text-sm text-muted mb-4" th:if="${currentUser == null or !currentUser.mustChangePassword}">
|
||||
Смените пароль, если хотите. После сохранения вы останетесь в системе.
|
||||
</p>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
<form class="card form-card form-stack" th:action="@{/change-password}" method="post">
|
||||
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" required></div>
|
||||
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div>
|
||||
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<button class="btn btn-primary" type="submit">Сменить пароль</button>
|
||||
<a th:if="${currentUser != null and !currentUser.mustChangePassword}"
|
||||
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<body>
|
||||
<div id="content">
|
||||
<div class="container-sm text-center">
|
||||
<p class="stat-value text-muted opacity-30" th:text="${status}">500</p>
|
||||
<p class="mt-4" th:text="${message}">Что-то пошло не так</p>
|
||||
<p class="stat-value text-muted opacity-30" th:if="${status != null and status >= 400}" th:text="${status}">500</p>
|
||||
<p class="mt-4" th:text="${message != null ? message : 'Что-то пошло не так'}">Что-то пошло не так</p>
|
||||
<a class="btn btn-secondary mt-6" th:href="@{/}">На главную</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,28 @@
|
||||
<title th:replace="${title}">LMS</title>
|
||||
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css}">
|
||||
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css}">
|
||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css}" disabled>
|
||||
<script th:src="@{/js/htmx.min.js}" defer></script>
|
||||
<script th:src="@{/js/highlight.min.js}" defer></script>
|
||||
<script>
|
||||
(function(){ var t = localStorage.getItem('lms-theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })();
|
||||
function toggleTheme(){ var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); }
|
||||
(function(){
|
||||
var t = localStorage.getItem('lms-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
window.__hljsSet = function(theme){
|
||||
var l = document.getElementById('hljs-light'), d = document.getElementById('hljs-dark');
|
||||
if (l && d) { l.disabled = theme !== 'light'; d.disabled = theme !== 'dark'; }
|
||||
};
|
||||
window.__hljsAll = function(){
|
||||
if (!window.hljs) return;
|
||||
document.querySelectorAll('pre code').forEach(function(el){ window.hljs.highlightElement(el); });
|
||||
};
|
||||
})();
|
||||
document.addEventListener('DOMContentLoaded', function(){ window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); });
|
||||
function toggleTheme(){
|
||||
var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark';
|
||||
e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); window.__hljsSet(t);
|
||||
}
|
||||
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); }
|
||||
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
|
||||
function openModalData(btn){ openModal(btn.dataset.modal); }
|
||||
@@ -22,14 +40,32 @@
|
||||
<div class="page-header-inner">
|
||||
<a th:href="@{/}" class="logo">LMS</a>
|
||||
<nav class="nav">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<th:block th:if="${currentUser != null}">
|
||||
<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>
|
||||
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
|
||||
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
||||
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
|
||||
<a th:unless="${currentUser.admin}" th:href="@{/student}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
|
||||
</th:block>
|
||||
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>
|
||||
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary btn-sm">Войти</a>
|
||||
<button type="button" onclick="toggleTheme()" class="btn btn-secondary btn-sm theme-btn"
|
||||
aria-label="Сменить тему" title="Сменить тему">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M12 7 a5 5 0 0 1 0 10 z" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<line x1="12" y1="1.4" x2="12" y2="3.2"/>
|
||||
<line x1="12" y1="20.8" x2="12" y2="22.6"/>
|
||||
<line x1="1.4" y1="12" x2="3.2" y2="12"/>
|
||||
<line x1="20.8" y1="12" x2="22.6" y2="12"/>
|
||||
<line x1="4.5" y1="4.5" x2="5.8" y2="5.8"/>
|
||||
<line x1="18.2" y1="18.2" x2="19.5" y2="19.5"/>
|
||||
<line x1="4.5" y1="19.5" x2="5.8" y2="18.2"/>
|
||||
<line x1="18.2" y1="5.8" x2="19.5" y2="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
|
||||
<button class="btn btn-secondary" type="submit">Выйти</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<nav class="text-sm mb-4">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep mx-2">/</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
|
||||
<span class="nav-sep mx-2">/</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
|
||||
th:text="${article.sectionTitle}">Раздел</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${article.title}">Статья</h1>
|
||||
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
|
||||
31
web/src/main/resources/templates/learn/chapter.html
Normal file
31
web/src/main/resources/templates/learn/chapter.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${chapter.title}">Глава</h1>
|
||||
|
||||
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
|
||||
В этой главе пока нет разделов.
|
||||
</div>
|
||||
|
||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
||||
<h2 class="group-title">
|
||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||
</h2>
|
||||
<ul class="list-pad text-sm">
|
||||
<li th:each="article : ${section.articles}">
|
||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">Статья</a>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,7 +18,7 @@
|
||||
Материалы ещё не добавлены.
|
||||
</div>
|
||||
|
||||
<details th:each="chapter : ${tree?.chapters}" class="card mb-4 accordion" open>
|
||||
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion" th:attr="open=${chStat.last}">
|
||||
<summary class="card-body acc-head">
|
||||
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
|
||||
<span class="acc-arrow" aria-hidden="true"></span>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
||||
|
||||
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc" open>
|
||||
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc">
|
||||
<summary class="sub-head">
|
||||
<h3 class="group-title m-0">
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/learn}" class="nav-link">← Все материалы</a>
|
||||
<h1 class="page-title mt-4" th:text="${section.title}">Раздел</h1>
|
||||
<p class="text-sm text-muted mb-6">Глава: <span th:text="${chapter.title}">—</span></p>
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}(c=${chapter.slug})}" class="nav-link" th:text="${chapter.title}">Глава</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${section.title}">Раздел</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${section.title}">Раздел</h1>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Статья</th></tr></thead>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span th:if="${c.variantNumber}" class="badge badge-warn">вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
|
||||
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}">1</span>)</span>
|
||||
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2">
|
||||
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<span th:if="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
|
||||
<span th:each="c : ${gradeComments}" class="chip"
|
||||
th:classappend="${c.tone == 'positive'} ? chip-pos : (${c.tone == 'negative'} ? chip-neg : chip-neu)"
|
||||
th:text="${c.text}">коммент</span>
|
||||
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
|
||||
</div>
|
||||
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
|
||||
|
||||
@@ -31,14 +25,66 @@
|
||||
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
<div class="card form-card form-stack mb-4">
|
||||
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт (истёк срок сдачи или уже назначено индивидуальное задание на защиту). Вы по-прежнему можете скачать отправленные работы ниже.</p>
|
||||
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
|
||||
<div><input class="input" name="files" type="file" multiple required></div>
|
||||
<button class="btn btn-primary" type="submit">Отправить решение</button>
|
||||
</form>
|
||||
<div th:if="${canSubmit and #lists.isEmpty(submissions)}" class="text-muted text-sm">Вы ещё ничего не сдавали.</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">История сдач</h3>
|
||||
<div class="card card-flush mb-6">
|
||||
<table>
|
||||
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${submissions}">
|
||||
<td th:text="${s.seq}">1</td>
|
||||
<td class="text-muted" th:text="${s.createdAt}"></td>
|
||||
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
|
||||
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${canSeeGrade}">
|
||||
<h2 class="section-title">Оценка и комментарии</h2>
|
||||
<div class="card card-body mb-6">
|
||||
<p class="mb-4">Ваша оценка за лабораторную: <span class="badge badge-ok font-mono" th:text="${grade}"></span></p>
|
||||
<div class="review-cols">
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsPositive)}">
|
||||
<h3 class="group-title tone-positive-text">Позитивный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsPositive}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsNegative)}">
|
||||
<h3 class="group-title tone-negative-text">Негативный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsNegative}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsNeutral)}">
|
||||
<h3 class="group-title">Нейтральный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsNeutral}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted"
|
||||
th:if="${#lists.isEmpty(commentsPositive) and #lists.isEmpty(commentsNegative) and #lists.isEmpty(commentsNeutral)}">
|
||||
Комментариев к оценке нет.
|
||||
</p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<h2 class="section-title">Защита лабораторной</h2>
|
||||
<div class="card card-body form-stack">
|
||||
<div th:if="${defense == null}" class="text-muted">
|
||||
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
|
||||
</div>
|
||||
<th:block th:if="${defense != null and defense.status == 'waiting'}">
|
||||
<p class="text-sm">Подойдите к преподавателю: он выдаст вам одноразовый код для начала защиты.</p>
|
||||
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
|
||||
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
@@ -47,7 +93,7 @@
|
||||
</form>
|
||||
</th:block>
|
||||
<div th:if="${defense != null and defense.status == 'started'}">
|
||||
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}" data-labid="${lab.id}"></p>
|
||||
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}"></p>
|
||||
<h3 class="group-title">Ваше индивидуальное задание</h3>
|
||||
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
|
||||
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post">
|
||||
@@ -55,52 +101,12 @@
|
||||
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.defense-timer').forEach(function (el) {
|
||||
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
|
||||
var tick = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}, 1000);
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
});
|
||||
</script>
|
||||
<div th:if="${defense != null and defense.status == 'ready'}">
|
||||
<p class="badge badge-ok defense-note">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
|
||||
<p class="badge badge-ok">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
|
||||
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
<div class="card form-card form-stack mb-4">
|
||||
<form th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Пока не истёк срок, можно пересывать столько раз, сколько нужно — засчитывается последняя попытка.</p>
|
||||
<div><input class="input" name="files" type="file" multiple required></div>
|
||||
<div th:if="${canSubmit}">
|
||||
<button class="btn btn-primary" type="submit">Отправить решение</button>
|
||||
</div>
|
||||
<div th:unless="${canSubmit}" class="alert alert-warning">Срок сдачи истёк — отправка закрыта.</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">История сдач</h3>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${submissions}">
|
||||
<td th:text="${s.seq}">1</td>
|
||||
<td class="text-muted" th:text="${#strings.replace(s.createdAt, 'T', ' ')}"></td>
|
||||
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
|
||||
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Вы ещё ничего не сдавали.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></body>
|
||||
<script src="/js/defense-timer.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span th:if="${c.variantNumber}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
|
||||
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}"></span>)</span>
|
||||
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
|
||||
</div>
|
||||
<p class="text-sm text-muted mt-2" th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user