Phase 1 fixes: broken dialogs, score width, status badge, MSK date round-trip
- group/lab/labs: move dialogs/scripts INSIDE the #content fragment (they were outside — the layout fragment dropped them, which is why 'add student' did nothing) - group.html toast had swallowed the dialog (th:if createdStudent) — split properly - .input-score width no longer overridden by .inline-form .input-sm (10rem min) - lab page: remove separate 'change status' button — status badge is now a toggle button itself (confirm dialog kept) - DateUtils.localInput/toUtcIso: dates entered in datetime-local are Moscow-local, stored as UTC instant, shown back MSK — correct round-trip both ways - academic parseInstant: naive local datetimes treated as Europe/Moscow - README/module docs left as-is
This commit is contained in:
@@ -17,7 +17,7 @@ import java.math.BigDecimal
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneId
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -291,7 +291,7 @@ class LabService(
|
|||||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||||
return runCatching { Instant.parse(v) }
|
return runCatching { Instant.parse(v) }
|
||||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||||
.recoverCatching { LocalDateTime.parse(v).atOffset(ZoneOffset.UTC).toInstant() }
|
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.ysu.lms.web
|
package ru.ysu.lms.web
|
||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -8,14 +9,33 @@ import java.time.format.DateTimeFormatter
|
|||||||
object DateUtils {
|
object DateUtils {
|
||||||
private val MSK = ZoneId.of("Europe/Moscow")
|
private val MSK = ZoneId.of("Europe/Moscow")
|
||||||
private val FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
private val FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||||
|
private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||||
|
|
||||||
|
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
|
||||||
fun formatMsk(value: String?): String? =
|
fun formatMsk(value: String?): String? =
|
||||||
value?.let {
|
toZoned(value)?.format(FORMAT)
|
||||||
|
|
||||||
|
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
|
||||||
|
fun localInput(value: String?): String? =
|
||||||
|
toZoned(value)?.format(INPUT_FORMAT) ?: ""
|
||||||
|
|
||||||
|
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
|
||||||
|
fun toUtcIso(localValue: String?): String? =
|
||||||
|
localValue?.takeIf { it.isNotBlank() }?.let { s ->
|
||||||
try {
|
try {
|
||||||
val zdt = ZonedDateTime.ofInstant(Instant.parse(it), MSK)
|
LocalDateTime.parse(s, INPUT_FORMAT)
|
||||||
zdt.format(FORMAT)
|
.atZone(MSK)
|
||||||
|
.toInstant()
|
||||||
|
.toString()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
it
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toZoned(value: String?): ZonedDateTime? =
|
||||||
|
try {
|
||||||
|
value?.let { ZonedDateTime.ofInstant(Instant.parse(it), MSK) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -285,8 +285,8 @@ class AdminController(
|
|||||||
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
||||||
model.addAttribute("deadlineMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.deadlineAt))
|
model.addAttribute("deadlineMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.deadlineAt))
|
||||||
model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt))
|
model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt))
|
||||||
model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
|
model.addAttribute("deadlineLocal", ru.ysu.lms.web.DateUtils.localInput(lab.deadlineAt))
|
||||||
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
|
model.addAttribute("defenseLocal", ru.ysu.lms.web.DateUtils.localInput(lab.defenseAt))
|
||||||
return "admin/lab"
|
return "admin/lab"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,8 +336,8 @@ class AdminController(
|
|||||||
LabCreatePayload(
|
LabCreatePayload(
|
||||||
number = number,
|
number = number,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
deadlineAt = deadlineAt?.takeIf { it.isNotBlank() },
|
deadlineAt = ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt),
|
||||||
defenseAt = defenseAt?.takeIf { it.isNotBlank() },
|
defenseAt = ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt),
|
||||||
pointsMax = java.math.BigDecimal(pointsMax),
|
pointsMax = java.math.BigDecimal(pointsMax),
|
||||||
status = status,
|
status = status,
|
||||||
descriptionMd = descriptionMd,
|
descriptionMd = descriptionMd,
|
||||||
@@ -425,8 +425,8 @@ class AdminController(
|
|||||||
val lab = api.labDetail(token(session), id)
|
val lab = api.labDetail(token(session), id)
|
||||||
// datetime-local требует формат yyyy-MM-dd'T'HH:mm
|
// datetime-local требует формат yyyy-MM-dd'T'HH:mm
|
||||||
model.addAttribute("lab", lab)
|
model.addAttribute("lab", lab)
|
||||||
model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
|
model.addAttribute("deadlineLocal", ru.ysu.lms.web.DateUtils.localInput(lab.deadlineAt))
|
||||||
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
|
model.addAttribute("defenseLocal", ru.ysu.lms.web.DateUtils.localInput(lab.defenseAt))
|
||||||
return "admin/lab-edit"
|
return "admin/lab-edit"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ class AdminController(
|
|||||||
): String = try {
|
): String = try {
|
||||||
api.updateLab(
|
api.updateLab(
|
||||||
token(session), id,
|
token(session), id,
|
||||||
LabUpdatePayload(title, deadlineAt?.takeIf { it.isNotBlank() }, defenseAt?.takeIf { it.isNotBlank() }, pointsMax, status, descriptionMd),
|
LabUpdatePayload(title, ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt), ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt), pointsMax, status, descriptionMd),
|
||||||
)
|
)
|
||||||
redirect.addFlashAttribute("notice", "Лабораторная сохранена")
|
redirect.addFlashAttribute("notice", "Лабораторная сохранена")
|
||||||
"redirect:/admin/labs/$id"
|
"redirect:/admin/labs/$id"
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ tr:last-child td { border-bottom: none; }
|
|||||||
.inline-form .input-sm { width: auto; min-width: 10rem; }
|
.inline-form .input-sm { width: auto; min-width: 10rem; }
|
||||||
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
|
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
|
||||||
.input-score { width: 4.5rem; }
|
.input-score { width: 4.5rem; }
|
||||||
|
.inline-form .input-sm { width: auto; min-width: 0; }
|
||||||
|
.inline-form .input-score, input[type="number"].input-score { width: 4.5rem; min-width: 4.5rem; flex: 0 0 auto; }
|
||||||
.filter-select { width: auto; }
|
.filter-select { width: auto; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div th:if="${createdStudent}" class="card import-card toast">
|
<div th:if="${createdStudent}" class="card import-card toast">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -115,8 +114,9 @@
|
|||||||
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
|
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
|
||||||
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
|
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
<dialog id="modal-add-student" class="modal">
|
<dialog id="modal-add-student" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Добавить студента</h2>
|
<h2 class="modal-title">Добавить студента</h2>
|
||||||
@@ -144,5 +144,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
|
Статус: <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}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
|
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
|
||||||
|
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</button>
|
||||||
</form>
|
</form>
|
||||||
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
||||||
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="modal-meta" class="modal">
|
<dialog id="modal-meta" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -257,5 +257,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="modal-create-lab" class="modal">
|
<dialog id="modal-create-lab" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -72,5 +72,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user