Learn UI: prev/next navigation, sidebar outline, editing on every page; merge import; defense date

- article page: ← / → navigation across the whole material tree (including
  between sections), sticky sidebar outline with the current article highlighted
- editing on chapter, section and article pages: rename, publish/hide, delete,
  create section/article, move articles while deleting a section; forms return
  to the page they were opened from (validated internal `back` parameter)
- article page opens the edit dialog directly via ?edit=1 (used by "Править"
  links on section/chapter pages)
- import: optional sync flag — with it stale sections/articles of archived
  chapters are deleted (previous behaviour), without it import only adds and
  updates, so material edited in the UI is not overwritten
- student area: lab page and lab cards now show the defense date next to the
  submission deadline
- fix SpEL crash on the student lab page (`and` with nullable strings)
This commit is contained in:
2026-09-17 17:14:16 +03:00
parent 6246d4c8d3
commit df5020fea6
15 changed files with 533 additions and 61 deletions

View File

@@ -400,7 +400,7 @@ class ContentService(
* картинки в assets/.
*/
@Transactional
fun importZip(filename: String, bytes: ByteArray): ContentImportResult {
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
@@ -509,8 +509,10 @@ class ContentService(
artCount += 1
}
// Синхронизация: в главах из архива удаляем разделы и статьи, которых в архиве нет.
for (chapterId in touchedChapters) {
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
for (chapterId in if (sync) touchedChapters else emptySet()) {
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
if (section.id in seenSections) {
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {

View File

@@ -79,9 +79,12 @@ class ContentAdminController(private val contentService: ContentService) {
}
@PostMapping("/import")
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
): ContentImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes)
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync)
}
@GetMapping("/articles")