diff --git a/deploy/IMPORT.md b/deploy/IMPORT.md index 412bf74..a8269b6 100644 --- a/deploy/IMPORT.md +++ b/deploy/IMPORT.md @@ -70,6 +70,10 @@ assets/ # картинки для md Материалы видны всем; для редактирования — «Материалы» с правами администратора. +Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве» +(синхронизация — архив считается источником истины) и без неё (merge — только добавить и +обновить, правки в приложении сохраняются). Для догрузки отдельных статей используйте merge. + Медиа в материалах: картинки и видео кладутся в `assets/` архива и вставляются директивами `:::figure assets/имя.png | Подпись` и `:::video assets/имя.mp4 | Подпись`. Лимиты: картинки — до 10 МБ (`png/jpg/jpeg/gif/svg/webp`), видео — до 100 МБ (`mp4/webm/ogg`). Nginx и сервисы diff --git a/docs/06-authoring.md b/docs/06-authoring.md index ef85d07..375bb09 100644 --- a/docs/06-authoring.md +++ b/docs/06-authoring.md @@ -85,12 +85,29 @@ printf("Ответ: 42\n"); Поддерживаются `mp4` (рекомендуется, кодек H.264), `webm`, `ogg` — до **100 МБ** на файл. Плеер поддерживает перемотку (сервер отдаёт файл частями). +## Правка прямо на страницах материалов + +Администратору доступны действия не только на главной `/learn`, но и на каждой странице: + +* **статья** — «Править» (модальное окно с markdown), «Скрыть/Опубликовать», «Удалить», + а также навигация **← предыдущая / следующая →** (работает и между разделами) и + **боковое содержание** со всеми главами, разделами и статьями; +* **раздел** — «+ Статья», «Переименовать раздел», «Скрыть/Опубликовать», + «Перенести статьи и удалить», «Удалить раздел»; у каждой статьи в списке — «Править» + (открывает статью с готовым окном правки), скрытие и удаление; +* **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»; + у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править». + ## Импорт: что происходит с материалами * Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). -* Разделы и статьи, которых в архиве нет, но они есть в **главах из архива**, удаляются — - архив считается источником истины (в уведомлении пишется «удалено устаревших: N»). +* Режим **синхронизации** (галка в форме импорта) дополнительно **удаляет** разделы и статьи + главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»). +* Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть + в архиве. Так можно догрузить отдельные статьи, **не затрагивая правки, сделанные в приложении**. * Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную. +* Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать + в режиме merge: остальные материалы останутся как есть. * Ссылки вида `assets/имя` автоматически заменяются на публичный адрес `/api/v1/content/assets/...`; сами файлы складываются в хранилище. diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index 4f0fa0d..4109fb4 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -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)) { diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index 49a7cda..e941900 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -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") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index 67396fb..56481d4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -29,6 +29,7 @@ class ContentAdminController( @PostMapping("/import") fun import( @RequestParam("file") file: MultipartFile, + @RequestParam(defaultValue = "false") sync: Boolean, session: HttpSession, redirect: RedirectAttributes, ): String { @@ -37,12 +38,13 @@ class ContentAdminController( return "redirect:/admin/content" } return try { - val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes) + val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes, sync) + val modeNote = if (sync) "" else " (режим «только добавить», удаления не было)" val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else "" redirect.addFlashAttribute( "notice", "Импортировано: глав ${result.chapters}, разделов ${result.sections}, " + - "статей ${result.articles}, медиафайлов ${result.assets}$removedNote", + "статей ${result.articles}, медиафайлов ${result.assets}$removedNote$modeNote", ) "redirect:/admin/content" } catch (ex: ApiException) { @@ -64,6 +66,7 @@ class ContentAdminController( @PathVariable kind: String, @RequestParam id: String, @RequestParam published: Boolean, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { @@ -72,7 +75,7 @@ class ContentAdminController( "notice", if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", ) - "redirect:/learn" + redirectBack(back, "redirect:/learn") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/learn" @@ -83,12 +86,13 @@ class ContentAdminController( @PathVariable id: String, @RequestParam title: String, @RequestParam bodyMd: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.updateContentArticle(token(session), id, title, bodyMd) redirect.addFlashAttribute("notice", "Статья сохранена") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content/articles/$id/edit" @@ -97,12 +101,13 @@ class ContentAdminController( @PostMapping("/articles/{id}/delete") fun delete( @PathVariable id: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.deleteContentArticle(token(session), id) redirect.addFlashAttribute("notice", "Статья удалена") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -112,12 +117,13 @@ class ContentAdminController( fun createChapter( @RequestParam title: String, @RequestParam(required = false) slug: String?, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.createChapter(token(session), title.trim(), slug) redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -127,12 +133,13 @@ class ContentAdminController( fun renameChapter( @PathVariable id: String, @RequestParam title: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.renameChapter(token(session), id, title.trim()) redirect.addFlashAttribute("notice", "Глава переименована") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -141,12 +148,13 @@ class ContentAdminController( @PostMapping("/chapters/{id}/delete") fun deleteChapter( @PathVariable id: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.deleteChapter(token(session), id) redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -157,12 +165,13 @@ class ContentAdminController( @RequestParam chapterId: String, @RequestParam title: String, @RequestParam(required = false) slug: String?, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.createSection(token(session), chapterId, title.trim(), slug) redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -172,12 +181,13 @@ class ContentAdminController( fun renameSection( @PathVariable id: String, @RequestParam title: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.renameSection(token(session), id, title.trim()) redirect.addFlashAttribute("notice", "Раздел переименован") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -187,6 +197,7 @@ class ContentAdminController( fun deleteSection( @PathVariable id: String, @RequestParam(required = false) moveTo: String?, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { @@ -197,7 +208,7 @@ class ContentAdminController( api.deleteSectionWithMove(token(session), id, moveTo) redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён") } - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -211,6 +222,7 @@ class ContentAdminController( @RequestParam(required = false) newSectionTitle: String?, @RequestParam title: String, @RequestParam(required = false, defaultValue = "") bodyMd: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { @@ -221,7 +233,7 @@ class ContentAdminController( title.trim(), bodyMd, ) redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" @@ -231,17 +243,26 @@ class ContentAdminController( fun moveArticle( @PathVariable id: String, @RequestParam sectionId: String, + @RequestParam(required = false) back: String?, session: HttpSession, redirect: RedirectAttributes, ): String = try { api.moveArticle(token(session), id, sectionId) redirect.addFlashAttribute("notice", "Статья перемещена") - "redirect:/admin/content" + redirectBack(back, "redirect:/admin/content") } catch (ex: ApiException) { redirect.addFlashAttribute("error", ex.message) "redirect:/admin/content" } + /** Возврат туда, откуда пришли: принимаем только внутренние пути. */ + private fun redirectBack(back: String?, fallback: String): String = + if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) { + "redirect:$back" + } else { + fallback + } + private fun token(session: HttpSession): String = session.getAttribute(SessionKeys.TOKEN) as? String ?: throw ApiException(401, "Сессия истекла, войдите заново") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 10e7087..fcdb815 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -502,8 +502,14 @@ class ApiClient( } } - fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto { + fun importContent( + token: String, + filename: String, + bytes: ByteArray, + sync: Boolean = true, + ): ContentImportResultDto { val parts = LinkedMultiValueMap() + parts.add("sync", sync.toString()) parts.add("file", object : ByteArrayResource(bytes) { override fun getFilename(): String = filename }) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt index 4000cea..44ec92c 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -13,6 +13,16 @@ import ru.ysu.lms.web.api.ContentTreeNodeDto import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser +/** Ссылка на статью для навигации «назад/далее» и бокового содержания. */ +data class LearnArticleLink( + val chapterSlug: String = "", + val chapterTitle: String = "", + val sectionSlug: String = "", + val sectionTitle: String = "", + val slug: String = "", + val title: String = "", +) + @Controller class LearnController( private val api: ApiClient, @@ -25,6 +35,17 @@ class LearnController( private fun isAdmin(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true + private fun sectionChoices(tree: ContentTreeNodeDto): List> = + tree.chapters.flatMap { chapter -> + chapter.sections.map { section -> + mapOf( + "id" to section.id, + "chapterId" to chapter.id, + "label" to "${chapter.title} / ${section.title}", + ) + } + } + @GetMapping("/learn") fun index(session: HttpSession, model: Model): String { val t = token(session) @@ -33,18 +54,7 @@ class LearnController( model.addAttribute("tree", tree) val articles = if (admin && t != null) api.contentTreeList(t) else emptyList() model.addAttribute("articles", articles) - model.addAttribute( - "sectionChoices", - tree.chapters.flatMap { chapter -> - chapter.sections.map { section -> - mapOf( - "id" to section.id, - "chapterId" to chapter.id, - "label" to "${chapter.title} / ${section.title}", - ) - } - }, - ) + model.addAttribute("sectionChoices", sectionChoices(tree)) return "learn/index" } @@ -58,6 +68,9 @@ class LearnController( val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" model.addAttribute("tree", tree) model.addAttribute("chapter", ch) + model.addAttribute("sectionChoices", sectionChoices(tree)) + model.addAttribute("back", "/learn/$chapter") + model.addAttribute("backParent", "/learn") return "learn/chapter" } @@ -74,6 +87,9 @@ class LearnController( model.addAttribute("tree", tree) model.addAttribute("chapter", ch) model.addAttribute("section", sec) + model.addAttribute("sectionChoices", sectionChoices(tree)) + model.addAttribute("back", "/learn/$chapter/$section") + model.addAttribute("backParent", "/learn/$chapter") return "learn/section" } @@ -85,12 +101,39 @@ class LearnController( session: HttpSession, model: Model, ): String { + val admin = isAdmin(session) + val tree: ContentTreeNodeDto = api.contentTree(token(session), all = admin) + val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" + val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn" + if (sec.articles.none { it.slug == article }) return "redirect:/learn" + val a: ContentArticleDto = try { - api.contentArticle(token(session), chapter, section, article, all = isAdmin(session)) + api.contentArticle(token(session), chapter, section, article, all = admin) } catch (ex: ApiException) { return "redirect:/learn" } + + // Плоский список всех статей по порядку — для навигации «назад/далее» через разделы. + val flat = tree.chapters.flatMap { c -> + c.sections.flatMap { s -> + s.articles.map { art -> + LearnArticleLink( + chapterSlug = c.slug, chapterTitle = c.title, + sectionSlug = s.slug, sectionTitle = s.title, + slug = art.slug, title = art.title, + ) + } + } + } + val index = flat.indexOfFirst { + it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article + } + model.addAttribute("tree", tree) model.addAttribute("article", a.toView(markdown.render(a.bodyMd))) + model.addAttribute("prevArticle", flat.getOrNull(index - 1)) + model.addAttribute("nextArticle", flat.getOrNull(index + 1)) + model.addAttribute("back", "/learn/$chapter/$section/$article") + model.addAttribute("backParent", "/learn/$chapter/$section") return "learn/article" } } @@ -104,6 +147,7 @@ data class LearnArticleView( val slug: String, val title: String, val bodyHtml: String, + val bodyMd: String, val published: Boolean, ) @@ -116,5 +160,6 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV slug = slug, title = title, bodyHtml = bodyHtml, + bodyMd = bodyMd, published = published, ) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index bad9ebf..16e9a46 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -89,6 +89,7 @@ class StudentController( val subs = api.mySubmissions(t, id) model.addAttribute("submissions", subs.map { it.copyMsk() }) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) + model.addAttribute("defenseMsk", DateUtils.formatMsk(lab.defenseAt)) val gid = studentGroupId(t) val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null model.addAttribute("canSeeGrade", myGrade != null) diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index e1957f6..dd9003b 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -367,3 +367,20 @@ th.th-filterable { padding-bottom: 0.35rem; } border-radius: 0.5rem; padding: 0.5rem 0.75rem; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem; } + +/* Чтение материалов: контент + боковое содержание */ +.learn-layout { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.5rem; align-items: start; } +.learn-side { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow: auto; font-size: 0.875rem; } +.side-chapter { font-weight: 600; margin: 0.6rem 0 0.2rem; } +.side-section { margin: 0.25rem 0 0.5rem 0.75rem; } +.side-section > a { color: var(--c-text-muted); } +.side-articles { list-style: none; margin: 0.2rem 0 0 0.75rem; padding: 0; } +.side-articles li { margin: 0.1rem 0; } +.side-articles a { display: inline-block; } +.side-current { font-weight: 600; text-decoration: underline; } +.article-nav { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.article-nav .ml-auto, .article-nav a:last-child:not(:only-child) { margin-left: auto; } +@media (max-width: 1000px) { + .learn-layout { grid-template-columns: 1fr; } + .learn-side { position: static; max-height: none; order: -1; } +} diff --git a/web/src/main/resources/templates/learn/article.html b/web/src/main/resources/templates/learn/article.html index a7bd8f7..72b927c 100644 --- a/web/src/main/resources/templates/learn/article.html +++ b/web/src/main/resources/templates/learn/article.html @@ -3,21 +3,127 @@ Статья
- -

- Статья - скрыто от студентов -

-
+
+
+ + +

+ Статья + скрыто от студентов +

+ +
+ +
+ + + + + +
+ К главе +
+ + + +
+
+ + + +
+ + +
+ + +
+ + + + + + +
- \ No newline at end of file + diff --git a/web/src/main/resources/templates/learn/chapter.html b/web/src/main/resources/templates/learn/chapter.html index a43ce63..fc64302 100644 --- a/web/src/main/resources/templates/learn/chapter.html +++ b/web/src/main/resources/templates/learn/chapter.html @@ -8,29 +8,160 @@ / Глава +

Глава скрыто

+
+ + +
+ + + + + +
+
+ + + +
+
+
В этой главе пока нет разделов.
-

- Раздел - скрыто -

+
+

+ Раздел + скрыто +

+
+ +
+ + + + + +
+ +
+ + + +
+
+
+ + + + + + + + + + + +
+ + + + + + + + + + diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 3b06c99..6f7d11f 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -198,6 +198,14 @@

+ +

+ Без этой галочки импорт только добавляет и обновляет материалы из архива — + статьи, отредактированные прямо в приложении, не затрагиваются. +

diff --git a/web/src/main/resources/templates/learn/section.html b/web/src/main/resources/templates/learn/section.html index 45a3e19..86a36f8 100644 --- a/web/src/main/resources/templates/learn/section.html +++ b/web/src/main/resources/templates/learn/section.html @@ -10,13 +10,36 @@ / Раздел +

Раздел скрыто

+ +
+ + +
+ + + + + +
+ +
+ + + +
+
+
- + + - +
Статья
СтатьяДействия
@@ -24,11 +47,91 @@ th:text="${article.title}">— скрыто + Править +
+ + + + + +
+
+ + + +
+
Статей пока нет.
Статей пока нет.
+ + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html index 070ea11..35ac798 100644 --- a/web/src/main/resources/templates/student/lab.html +++ b/web/src/main/resources/templates/student/lab.html @@ -8,7 +8,11 @@

ЛР

Вариант -

Срок сдачи: МСК

+

+ Срок сдачи: МСК + · + Защита: МСК +

diff --git a/web/src/main/resources/templates/student/labs.html b/web/src/main/resources/templates/student/labs.html index 8396b01..fc4822d 100644 --- a/web/src/main/resources/templates/student/labs.html +++ b/web/src/main/resources/templates/student/labs.html @@ -16,7 +16,11 @@ не сдано оценка: -

—

+

+ — + · + — +

Опубликованных лабораторных пока нет.