Phase 2+5: lab submissions and student lab flow

- progress: submissions table+V4; SubmissionService (deadline-gated submit,
  unlimited resubmits, per-student history, zip download); files on disk
  data/submissions/{student}/{lab}/{seq}/
- student API: POST submit (multipart), GET me submissions, download; admin
  GET lab submissions + admin download (zip pack)
- progress enrich(): labId in MeAssignment; web DTO aligned
- web: /student dashboard (avg/best/worst cards), /student/labs cards,
  /student/labs/{id}: condition md, variant block, submit form, history;
  'Лабораторные' link in nav for students
- admin: submissions column on lab assignment table + download link
- fixed long-standing template bug: broken @{} expression (missing }) in
  admin/lab and student/lab
This commit is contained in:
2026-09-12 00:20:46 +03:00
parent 7818f459bd
commit a3be71db27
21 changed files with 628 additions and 49 deletions

View File

@@ -10,6 +10,10 @@ server:
spring:
application:
name: web
servlet:
multipart:
max-file-size: 30MB
max-request-size: 120MB
thymeleaf:
cache: false

View File

@@ -98,7 +98,7 @@
<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></tr></thead>
<thead><tr><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>
@@ -130,8 +130,12 @@
</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>
</tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="6" class="empty">В группе нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="7" class="empty">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>

View File

@@ -26,6 +26,7 @@
<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>
</th:block>
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>

View File

@@ -4,27 +4,44 @@
<body>
<div id="content">
<h1 class="page-title">Здравствуйте, <span th:text="${currentUser.name}">студент</span></h1>
<p class="text-sm text-muted mb-6">Ваши лабораторные работы</p>
<div th:if="${#lists.isEmpty(assignments)}" class="card card-body text-muted">
Пока нет назначенных лабораторных. Ожидайте, пока преподаватель распределит варианты.
<div class="grid grid-stats">
<div class="card card-body"><p class="stat-title">Сдано работ</p>
<p class="stat-value" th:text="${submittedCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Средний балл</p>
<p class="stat-value" th:if="${average != null}" th:text="${average}">—</p>
<p class="stat-value text-muted" th:if="${average == null}">—</p></div>
<div class="card card-body"><p class="stat-title">Лучшая оценка</p>
<p class="stat-value" th:if="${best != null}" th:text="${best.toPlainString()}">—</p>
<p class="stat-value text-muted" th:if="${best == null}">—</p></div>
<div class="card card-body"><p class="stat-title">Слабое место</p>
<p class="stat-value" th:if="${worst != null}" th:text="${worst.toPlainString()}">—</p>
<p class="stat-value text-muted" th:if="${worst == null}">—</p></div>
</div>
<div th:each="a : ${assignments}" class="card mb-6">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4">
<h2 class="section-title" th:text="|ЛР ${a.labNumber}. ${a.labTitle}|">ЛР</h2>
<div class="flex gap-2 items-center">
<span class="badge badge-ok">Вариант <span class="font-mono" th:text="${a.variantNumber}">—</span></span>
<span th:if="${a.levelTitle}" class="badge badge-warn" th:text="${a.levelTitle}">—</span>
<div class="flex flex-wrap gap-2 mt-8">
<a class="btn btn-primary" th:href="@{/student/labs}">Лабораторные</a>
<a class="btn btn-secondary" th:href="@{/learn}">Учебные материалы</a>
</div>
<h2 class="section-title mt-8">Мои лабораторные</h2>
<div class="grid grid-2">
<div th:each="c : ${cards}" class="card">
<div class="card-body">
<h3 class="section-title">
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}|">ЛР</a>
</h3>
<p class="text-sm" th:text="${c.title}">—</p>
<div class="flex flex-wrap gap-2 mt-2 text-sm">
<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>
</div>
</div>
<p class="text-sm text-muted" th:if="${a.variantTitle}" th:text="${a.variantTitle}">—</p>
<p class="text-sm text-muted" th:if="${a.deadlineMsk}" th:text="|Срок сдачи: ${a.deadlineMsk} (МСК)|">—</p>
<p class="text-sm text-muted" th:if="${a.defenseMsk}" th:text="|Защита: ${a.defenseMsk} (МСК)|">—</p>
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
</div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Лабораторных пока нет.</div>
</div>
</div>
</body>
</html>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="|ЛР ${lab.number} — LMS|">Лабораторная</title></head>
<body>
<div id="content">
<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:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
</div>
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
<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>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<th:block th:if="${assignment != null}">
<h2 class="section-title">Ваш вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span>
<span class="text-muted text-sm" th:if="${assignment.levelTitle}" th:text="|(${assignment.levelTitle})|"></span></h2>
<div class="card card-body md-body mb-6" th:utext="${statementHtml}"></div>
</th:block>
<div th:if="${assignment == null}" class="card card-body mb-6">
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
</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>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Лабораторные — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Лабораторные работы</h1>
<div class="grid grid-2">
<div th:each="c : ${cards}" class="card">
<div class="card-body">
<h2 class="section-title">
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}. ${c.title}|">ЛР</a>
</h2>
<div class="flex flex-wrap gap-2 text-sm mt-2">
<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>
</div>
<p class="text-sm text-muted mt-2" th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</p>
</div>
</div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Опубликованных лабораторных пока нет.</div>
</div>
</div>
</body>
</html>