diff --git a/README.md b/README.md index 80ab141..ef096d4 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,9 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD оценка и комментарии отдельным блоком, защита по одноразовому коду (30 мин). - **Публикация оценок**: в группе по каждой лабе — кнопка «опубликовать»; студент видит оценку и комментарии только после публикации (публикуется вся группа). -- **Комментарии к оценкам**: тональность (positive/negative/neutral), на странице - студента — ненумерованными списками в трёх колонках, хранятся в БД, - автоподсказки при вводе ранжируются по частоте использования. +- **Комментарии к оценкам**: общий (итоговый) комментарий по лабораторной + комментарии + с тональностью (positive/negative/neutral); на странице студента — ненумерованными списками + в трёх колонках, хранятся в БД, автоподсказки при вводе ранжируются по частоте использования. - **Архив сдач группы**: одна кнопка «Скачать все работы» — ZIP со всеми последними решениями группы, разложенными по лабам и студентам. - **Материалы** `/learn` доступны без входа; аккордеон глав: свёрнуты все, diff --git a/docs/02-deployment.md b/docs/02-deployment.md index e17e665..78f85ea 100644 --- a/docs/02-deployment.md +++ b/docs/02-deployment.md @@ -101,7 +101,7 @@ sequenceDiagram DC->>DC: docker build (5 стадий) DC->>PG: старт postgres (volume lms_pgdata) DC->>DC: старт identity → academic → progress → gateway → web - Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V3, academic V1..V5, progress V1..V8; у web миграций нет) + Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V3, academic V1..V5, progress V1..V9; у web миграций нет) ``` - Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`. diff --git a/docs/03-domain-model.md b/docs/03-domain-model.md index 8c4878d..7f88e7e 100644 --- a/docs/03-domain-model.md +++ b/docs/03-domain-model.md @@ -269,6 +269,7 @@ classDiagram +UUID studentId +UUID labId +BigDecimal score + +String generalComment +Instant updatedAt } class GradePublication { @@ -635,6 +636,7 @@ erDiagram uuid student_id PK uuid lab_id PK numeric score + text general_comment timestamptz updated_at } GRADE_PUBLICATIONS { diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index 42ec706..5a72900 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -134,6 +134,8 @@ data class GradeSetRequest( val labId: String, val score: BigDecimal, val comments: List = emptyList(), + /** Итоговый комментарий по работе в целом; может быть пустым. */ + val generalComment: String = "", ) data class CommentInput( @@ -158,6 +160,7 @@ data class GradeResponse( val labId: String, val score: BigDecimal, val comments: List = emptyList(), + val generalComment: String = "", ) data class SubmissionDto( diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt index 2bdc579..788ff8d 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt @@ -29,6 +29,8 @@ class GradeService( val key = LabGradeKey(sid, lid) val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) } grade.score = request.score + grade.generalComment = request.generalComment.trim() + grade.updatedAt = java.time.Instant.now() grades.save(grade) replaceComments(sid, lid, request.comments) return GradeResponse( @@ -36,6 +38,7 @@ class GradeService( request.labId, request.score, commentsOf(sid, lid).map { CommentOut(it.text, it.tone) }, + grade.generalComment, ) } @@ -122,6 +125,7 @@ class GradeService( labId = it.labId.toString(), score = it.score, comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) }, + generalComment = it.generalComment, ) } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt index d993801..74c5d51 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/LabGrade.kt @@ -17,6 +17,7 @@ class LabGrade( @Id @Column(nullable = false) var studentId: UUID = UUID(0, 0), @Id @Column(nullable = false) var labId: UUID = UUID(0, 0), @Column(nullable = false) var score: BigDecimal = BigDecimal.ZERO, + @Column(name = "general_comment", nullable = false) var generalComment: String = "", @Column(nullable = false) var updatedAt: Instant = Instant.now(), ) diff --git a/services/progress-service/src/main/resources/db/migration/V9__grade_general_comment.sql b/services/progress-service/src/main/resources/db/migration/V9__grade_general_comment.sql new file mode 100644 index 0000000..f33dc13 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V9__grade_general_comment.sql @@ -0,0 +1,2 @@ +-- Общий (итоговый) комментарий преподавателя по лабораторной для конкретного студента. +ALTER TABLE lab_grades ADD COLUMN general_comment text NOT NULL DEFAULT ''; diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt index 36de6f0..9d23cbb 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt @@ -82,6 +82,7 @@ data class SetGradeForm( val labId: String = "", val score: String = "", val commentsJson: String? = null, + val generalComment: String = "", ) data class SubmissionDownloadForm( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 938a4e0..8eef69b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -144,6 +144,7 @@ class AdminGroupController( val (group, labs) = loadGroupAndLabs(t, id) val grades = api.listGrades(t) val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } + val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } val memberIds = group.students.map { it.id } val memberIdSet = memberIds.toSet() @@ -168,7 +169,18 @@ class AdminGroupController( val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } } val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } } - val rows = buildRows(group.students, labs, gradeBy, commentsBy, publishedByLab, assignmentsBy, submissionsBy, defensesBy) + val rows = + buildRows( + group.students, + labs, + gradeBy, + generalCommentBy, + commentsBy, + publishedByLab, + assignmentsBy, + submissionsBy, + defensesBy, + ) fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, labPublication, submissionsBy))) return "admin/group" @@ -179,6 +191,7 @@ class AdminGroupController( students: List, labs: List, gradeBy: Map, + generalCommentBy: Map, commentsBy: Map>>, publishedByLab: Map>, assignmentsBy: Map>, @@ -200,6 +213,7 @@ class AdminGroupController( variantId = va?.variantId, variantManual = va?.manual == true, score = gradeBy["${s.id}|${lab.id}"], + generalComment = generalCommentBy["${s.id}|${lab.id}"].orEmpty(), published = publishedByLab.getValue(lab.id).contains(s.id), submissionSeq = sub?.seq, submissionId = sub?.id, @@ -344,7 +358,16 @@ class AdminGroupController( ): String = try { val comments = parseComments(form.commentsJson ?: "[]") - api.setGrade(token(session), GradeSetPayload(form.studentId, form.labId, form.score, comments)) + api.setGrade( + token(session), + GradeSetPayload( + studentId = form.studentId, + labId = form.labId, + score = form.score, + comments = comments, + generalComment = form.generalComment, + ), + ) redirect.addFlashAttribute("notice", "Оценка сохранена") "redirect:/admin/groups/$id" } catch (ex: ApiException) { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 0c60c54..7135c52 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -26,6 +26,7 @@ data class GroupLabCell( val variantId: String? = null, val variantManual: Boolean = false, val score: String? = null, + val generalComment: String = "", val published: Boolean = false, val submissionSeq: Int? = null, val submissionId: String? = null, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index a420d00..1aa4c7a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -261,6 +261,7 @@ data class GradeDto( val labId: String = "", val score: String = "", val comments: List = emptyList(), + val generalComment: String = "", ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -291,6 +292,7 @@ data class GradeSetPayload( val labId: String, val score: String, val comments: List = emptyList(), + val generalComment: String = "", ) @JsonIgnoreProperties(ignoreUnknown = true) 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 08c0153..ded0956 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 @@ -108,6 +108,7 @@ class StudentController( val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null model.addAttribute("canSeeGrade", myGrade != null) model.addAttribute("grade", myGrade?.score) + model.addAttribute("generalComment", myGrade?.generalComment.orEmpty()) model.addAttribute( "commentsPositive", myGrade?.comments?.filter { it.tone == "positive" } ?: emptyList(), diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index daa9232..950874e 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -17,6 +17,7 @@ body { винный — деструктивные действия и негативная тональность. */ /* muted-текст Bootstrap (.text-muted/placeholder) идёт через --bs-secondary-color с !important, поэтому переопределяем её переменные под нашу палитру в обеих темах */ +/* Строгая геометрия: скругления отключены намеренно — каркас, поля и кнопки прямоугольные. */ :root { --bs-secondary-color-rgb: 116, 116, 124; --bs-secondary-color: rgba(116, 116, 124, 0.9); @@ -24,8 +25,12 @@ body { --bs-body-bg: #fafafa; --bs-body-color: #131316; --bs-border-color: #e6e6e9; - --bs-border-radius: 0.5rem; - --bs-border-radius-lg: 0.625rem; + --bs-border-radius: 0; + --bs-border-radius-sm: 0; + --bs-border-radius-lg: 0; + --bs-border-radius-xl: 0; + --bs-border-radius-xxl: 0; + --bs-border-radius-pill: 0; --bs-link-color: #7a2333; --bs-link-hover-color: #9d2b3c; --c-bg: #fafafa; @@ -48,10 +53,10 @@ body { --c-tone-pos: #2f6b3a; --c-tone-neg: #9d2b3c; --c-tone-neu: #74747c; - --radius-xs: 0.125rem; - --radius-sm: 0.25rem; - --radius: 0.5rem; - --radius-lg: 0.625rem; + --radius-xs: 0; + --radius-sm: 0; + --radius: 0; + --radius-lg: 0; } [data-theme="dark"] { @@ -159,7 +164,7 @@ code { --bs-code-color: var(--c-text); color: var(--c-text); } } .nav-user { font-weight: 500; color: var(--c-accent); text-decoration: none; } .nav-user:hover { color: var(--c-accent-hover); } -.nav-user.defense-active::before { content: ""; display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--c-accent); margin-right: 0.4rem; vertical-align: middle; } +.nav-user.defense-active::before { content: ""; display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 0; background: var(--c-accent); margin-right: 0.4rem; vertical-align: middle; } .page-main { padding: 2.5rem 1.75rem 3.5rem; } @media (max-width: 720px) { .page-main { padding: 1.5rem 0.875rem 2.5rem; } } @@ -189,7 +194,7 @@ a { --bs-link-color: var(--c-accent); --bs-link-hover-color: var(--c-accent-hove * { scrollbar-width: thin; scrollbar-color: var(--c-border-strong) var(--c-bg); } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: var(--c-bg); } -::-webkit-scrollbar-thumb { background: var(--c-border-strong); border: 2px solid var(--c-bg); border-radius: 8px; } +::-webkit-scrollbar-thumb { background: var(--c-border-strong); border: 2px solid var(--c-bg); border-radius: 0; } ::-webkit-scrollbar-thumb:hover { background: var(--c-accent); } ::-webkit-scrollbar-corner { background: var(--c-bg); } @@ -427,7 +432,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami .pub-toggle { appearance: none; -webkit-appearance: none; width: 1rem; height: 1rem; margin: 0; padding: 0; cursor: pointer; - border-radius: 50%; border: 1.5px solid var(--c-border-strong); + border-radius: 0; border: 1.5px solid var(--c-border-strong); background: transparent; vertical-align: middle; transition: border-color 0.15s, box-shadow 0.15s; } @@ -466,7 +471,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-fami color: var(--c-text-muted); font-size: 0.75rem; text-decoration: none; cursor: pointer; } .cell-btn.st:hover { background: none; color: var(--c-text); } -.cell-btn.st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--c-border-strong); } +.cell-btn.st::before { content: ""; width: 6px; height: 6px; border-radius: 0; background: var(--c-border-strong); } .cell-btn.st.ok { color: var(--c-tone-pos); } .cell-btn.st.ok::before { background: var(--c-tone-pos); } .cell-btn.st.warn { color: #b45309; } @@ -557,6 +562,11 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; } .md-preview { max-width: 22rem; } /* ---- Комментарии к оценке (ЛК студента) ---- */ +.general-comment { + margin-bottom: 1rem; padding: 0.35rem 0 0.35rem 0.9rem; + border-left: 3px solid var(--c-accent); +} +.general-comment .group-title { margin-bottom: 0.35rem; } .review-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; } .review-col h3 { margin-bottom: 0.35rem; } .review-list { margin: 0; padding-left: 1.15rem; } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 6097e37..1170d81 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -20,6 +20,8 @@ function openGradeEditor(btn) { var title = $id('grade-title'); if (title) title.textContent = (btn.getAttribute('data-lab-name') || '') + ' — ' + (btn.getAttribute('data-student-name') || ''); $id('grade-score').value = btn.getAttribute('data-score') || ''; + var general = $id('grade-general'); + if (general) general.value = btn.getAttribute('data-general-comment') || ''; var box = $id('cs-rows'); box.innerHTML = ''; diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index ae1f8a9..ec17cbc 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -93,7 +93,7 @@ @@ -160,6 +160,9 @@
+
+
@@ -342,7 +345,7 @@
- +
diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 730abb9..36df317 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -196,7 +196,7 @@ - + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 59ce367..a49310f 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -13,22 +13,22 @@ - - + + - - - - - - + + + + + + - - - - - + + + + +
diff --git a/web/src/main/resources/templates/learn/article.html b/web/src/main/resources/templates/learn/article.html index 6d7d3d5..5e3feb2 100644 --- a/web/src/main/resources/templates/learn/article.html +++ b/web/src/main/resources/templates/learn/article.html @@ -138,7 +138,7 @@
- + diff --git a/web/src/main/resources/templates/learn/chapter.html b/web/src/main/resources/templates/learn/chapter.html index 8c5875f..db875a3 100644 --- a/web/src/main/resources/templates/learn/chapter.html +++ b/web/src/main/resources/templates/learn/chapter.html @@ -183,7 +183,7 @@ - + diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 173d1db..8633fbb 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -236,8 +236,8 @@ - - + + diff --git a/web/src/main/resources/templates/learn/section.html b/web/src/main/resources/templates/learn/section.html index 132a6c1..501119f 100644 --- a/web/src/main/resources/templates/learn/section.html +++ b/web/src/main/resources/templates/learn/section.html @@ -143,7 +143,7 @@ - + diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html index 096e021..fa69694 100644 --- a/web/src/main/resources/templates/student/lab.html +++ b/web/src/main/resources/templates/student/lab.html @@ -57,6 +57,10 @@

Оценка и комментарии

Ваша оценка за лабораторную:

+
+

Общий комментарий по лабораторной

+

—

+

Позитивный

@@ -107,7 +111,7 @@

Ожидайте преподавателя с проверкой.

- +