Phase 4: grade comments with frequency-ranked suggestions

- progress comment_templates (tone + use_count) and grade_comments tables;
  setGrade now accepts comments list; templates created/bumped automatically;
  GET /api/v1/comment-templates?q= returns suggestions ranked by frequency
- admin group page: per-grade ✎ dialog (score + editable comment rows with
  tone, datalist suggestions), chips under each score, data-comments JSON attr
- student lab page: score + colored tone chips
- fixed damaged button/attributes from earlier manual template surgery
This commit is contained in:
2026-09-12 01:51:23 +03:00
parent ccd31263d5
commit d355319a20
15 changed files with 305 additions and 18 deletions

View File

@@ -20,7 +20,7 @@ spring:
- id: progress - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/submissions/** - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/**
management: management:
endpoints: endpoints:

View File

@@ -128,12 +128,17 @@ data class GradeSetRequest(
val studentId: String, val studentId: String,
val labId: String, val labId: String,
val score: BigDecimal, val score: BigDecimal,
val comments: List<CommentInput> = emptyList(),
) )
data class CommentInput(val text: String, val tone: String = "neutral")
data class CommentOut(val text: String, val tone: String)
data class GradeResponse( data class GradeResponse(
val studentId: String, val studentId: String,
val labId: String, val labId: String,
val score: BigDecimal, val score: BigDecimal,
val comments: List<CommentOut> = emptyList(),
) )
data class SubmissionDto( data class SubmissionDto(

View File

@@ -84,6 +84,10 @@ class ProgressController(
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> = fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
service.listGrades().filter { it.studentId == jwt.subject } service.listGrades().filter { it.studentId == jwt.subject }
@GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
@PostMapping("/me/submissions/{labId}") @PostMapping("/me/submissions/{labId}")
fun submit( fun submit(
@PathVariable labId: UUID, @PathVariable labId: UUID,

View File

@@ -3,10 +3,14 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.VariantAssignment import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository
@@ -19,6 +23,8 @@ class ProgressService(
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository, private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient, private val identity: IdentityClient,
) { ) {
@@ -180,16 +186,60 @@ class ProgressService(
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) { if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5") throw BadRequestException("Оценка должна быть в диапазоне 0–5")
} }
val key = LabGradeKey(UUID.fromString(request.studentId), UUID.fromString(request.labId)) val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) } val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score grade.score = request.score
grades.save(grade) grades.save(grade)
return GradeResponse(request.studentId, request.labId, request.score) replaceComments(sid, lid, request.comments)
return GradeResponse(request.studentId, request.labId, request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
}
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
else { t.useCount += 1; t.tone = tone; templates.save(t) }
}
}
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> = fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) }
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade = gradeComments.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? { fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {

View File

@@ -0,0 +1,29 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "comment_templates")
class CommentTemplate(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true) var text: String = "",
@Column(nullable = false, length = 8) var tone: String = "neutral",
@Column(name = "use_count", nullable = false) var useCount: Int = 1,
)
@Entity
@Table(name = "grade_comments")
class GradeComment(
@Id var id: UUID = Ids.v7(),
@Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
@Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var text: String = "",
@Column(nullable = false, length = 8) var tone: String = "neutral",
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import java.util.UUID
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
fun findByText(text: String): CommentTemplate?
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
}
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
fun findByStudentId(studentId: UUID): List<GradeComment>
}

View File

@@ -0,0 +1,19 @@
-- Grade comments: reusable templates (frequency-ranked) + per-grade comments.
CREATE TABLE comment_templates (
id uuid PRIMARY KEY DEFAULT uuidv7(),
text text NOT NULL UNIQUE,
tone varchar(8) NOT NULL DEFAULT 'neutral', -- positive | neutral | negative
use_count integer NOT NULL DEFAULT 1
);
CREATE TABLE grade_comments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
text text NOT NULL,
tone varchar(8) NOT NULL DEFAULT 'neutral',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX grade_comments_grade_idx ON grade_comments (student_id, lab_id);

View File

@@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets
class AdminController( class AdminController(
private val api: ApiClient, private val api: ApiClient,
private val markdown: ru.ysu.lms.markdown.MarkdownRenderer, private val markdown: ru.ysu.lms.markdown.MarkdownRenderer,
private val mapper: tools.jackson.databind.ObjectMapper,
) { ) {
@GetMapping @GetMapping
@@ -119,6 +120,7 @@ class AdminController(
val labs = api.listLabs(t) val labs = api.listLabs(t)
val grades = api.listGrades(t) val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
val studentGrades = group.students.map { s -> val studentGrades = group.students.map { s ->
val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] } val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] }
val nums = scores.mapNotNull { it?.toBigDecimalOrNull() } val nums = scores.mapNotNull { it?.toBigDecimalOrNull() }
@@ -128,11 +130,17 @@ class AdminController(
studentNo = s.studentNo, studentNo = s.studentNo,
scores = scores, scores = scores,
average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(), average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(),
comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() },
commentsJson = mapper.writeValueAsString(labs.map { lab ->
(commentsBy["${"$"}{s.id}|${"$"}{lab.id}"] ?: emptyList()).map { (t, tone) -> mapOf("text" to t, "tone" to tone) }
}),
) )
} }
model.addAttribute("group", group) model.addAttribute("group", group)
model.addAttribute("labs", labs) model.addAttribute("labs", labs)
model.addAttribute("studentGrades", studentGrades) model.addAttribute("studentGrades", studentGrades)
model.addAttribute("commentOptions", api.suggestComments(t, ""))
return "admin/group" return "admin/group"
} }
@@ -142,10 +150,12 @@ class AdminController(
@RequestParam studentId: String, @RequestParam studentId: String,
@RequestParam labId: String, @RequestParam labId: String,
@RequestParam score: String, @RequestParam score: String,
@RequestParam(required = false) commentsJson: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.setGrade(token(session), GradeSetPayload(studentId, labId, score)) val comments = parseComments(commentsJson ?: "[]")
api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments))
redirect.addFlashAttribute("notice", "Оценка сохранена") redirect.addFlashAttribute("notice", "Оценка сохранена")
"redirect:/admin/groups/$id" "redirect:/admin/groups/$id"
} catch (ex: ApiException) { } catch (ex: ApiException) {
@@ -624,6 +634,24 @@ class AdminController(
"redirect:/admin/labs/$labId" "redirect:/admin/labs/$labId"
} }
private fun parseComments(json: String): List<ru.ysu.lms.web.api.CommentPayload> {
if (json.isBlank() || json == "[]") return emptyList()
return try {
val node = mapper.readTree(json)
buildList {
node.forEach { el ->
val text = el.path("text").asText("").trim()
if (text.isNotEmpty()) {
add(ru.ysu.lms.web.api.CommentPayload(text, el.path("tone").asText("neutral")))
}
}
}
} catch (_: Exception) {
emptyList()
}
}
private fun token(session: HttpSession): String = private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново") ?: throw ApiException(401, "Сессия истекла, войдите заново")

View File

@@ -31,4 +31,6 @@ data class AdminStudentGrades(
val studentNo: String, val studentNo: String,
val scores: List<String?>, val scores: List<String?>,
val average: Double?, val average: Double?,
val comments: List<List<Pair<String, String>>>,
val commentsJson: String,
) )

View File

@@ -306,6 +306,15 @@ class ApiClient(
.retrieve().body(GradeDto::class.java) .retrieve().body(GradeDto::class.java)
} }
fun suggestComments(token: String, query: String): List<CommentDto> {
val type = object : ParameterizedTypeReference<List<CommentDto>>() {}
return call {
client.get().uri("/api/v1/comment-templates?q={q}", query)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun myGrades(token: String): List<GradeDto> { fun myGrades(token: String): List<GradeDto> {
val type = object : ParameterizedTypeReference<List<GradeDto>>() {} val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
return call { return call {

View File

@@ -210,8 +210,12 @@ data class GradeDto(
val studentId: String = "", val studentId: String = "",
val labId: String = "", val labId: String = "",
val score: String = "", val score: String = "",
val comments: List<CommentDto> = emptyList(),
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto(val text: String = "", val tone: String = "neutral")
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class SubmissionDto( data class SubmissionDto(
val id: String = "", val id: String = "",
@@ -226,8 +230,11 @@ data class GradeSetPayload(
val studentId: String, val studentId: String,
val labId: String, val labId: String,
val score: String, val score: String,
val comments: List<CommentPayload> = emptyList(),
) )
data class CommentPayload(val text: String, val tone: String)
data class LabCreatePayload( data class LabCreatePayload(
val number: Int, val number: Int,
val title: String, val title: String,

View File

@@ -84,7 +84,9 @@ class StudentController(
model.addAttribute("assignment", assignment) model.addAttribute("assignment", assignment)
model.addAttribute("statementHtml", assignment?.statementHtml) model.addAttribute("statementHtml", assignment?.statementHtml)
model.addAttribute("submissions", api.mySubmissions(t, id)) model.addAttribute("submissions", api.mySubmissions(t, id))
model.addAttribute("grade", api.myGrades(t).firstOrNull { it.labId == id }?.score) val myGrade = api.myGrades(t).firstOrNull { it.labId == id }
model.addAttribute("grade", myGrade?.score)
model.addAttribute("gradeComments", myGrade?.comments ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt)) model.addAttribute("canSubmit", canSubmit(lab.deadlineAt))
val defense = api.myDefense(t, id) val defense = api.myDefense(t, id)

View File

@@ -217,4 +217,17 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.form-card { padding: 1.5rem; } .form-card { padding: 1.5rem; }
/* Header logo */ /* Header logo */
.link-inherit { font-size: 0.875rem; } .link-inherit { font-size: 0.875rem; }/* Chips (оценочные комментарии) */
.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; }
/* Слоты комментариев в диалоге */
.cs-row { display: flex; gap: 6px; align-items: center; }
.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; }
/* Defense timer */

View File

@@ -46,7 +46,7 @@
</div> </div>
<h2 class="section-title">Баллы за лабораторные</h2> <h2 class="section-title">Баллы за лабораторные</h2>
<p class="text-sm text-muted mb-4">Введите балл (0–5) и нажмите «OK». Средний балл считается по оценённым лабам.</p> <p class="text-sm text-muted mb-4">Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.</p>
<div class="card card-flush mb-6 overflow-x-auto"> <div class="card card-flush mb-6 overflow-x-auto">
<table> <table>
<thead> <thead>
@@ -62,14 +62,21 @@
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td th:text="${row.fullName}">—</td> <td th:text="${row.fullName}">—</td>
<td th:each="score, iter : ${row.scores}"> <td th:each="score, iter : ${row.scores}">
<form class="inline-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"> <div>
<input type="hidden" name="studentId" th:value="${row.studentId}"> <div class="score-cell-root">
<input type="hidden" name="labId" th:value="${labs[iter.index].id}"> <form class="inline-form">
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5" <input type="hidden" name="_csrf" th:value="${csrfToken}">
th:value="${score}"> <input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
<button class="btn btn-secondary btn-sm" type="submit">OK</button> <button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
<input type="hidden" name="_csrf" th:value="${csrfToken}"> th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${labs[iter.index].id},data-lab-name='ЛР ' + ${labs[iter.index].number},data-score=${score},data-comments=${row.commentsJson},data-ci=${iter.index}">✎</button>
</form> </form>
</div>
<div>
<span th:each="c : ${row.comments[iter.index]}" class="chip"
th:classappend="${c.second == 'positive'} ? chip-pos : (${c.second == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.first}">коммент</span>
</div>
</div>
</td> </td>
<td class="text-center font-semibold"> <td class="text-center font-semibold">
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span> <span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
@@ -144,6 +151,98 @@
</form> </form>
</div> </div>
</dialog> </dialog>
<dialog id="modal-grade" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"
onsubmit="return csSerialize()">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<div id="cs-rows"></div>
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<datalist id="cs-list"></datalist>
<script>
var CS_TEMPLATES = [];
</script>
<script th:inline="javascript">
CS_TEMPLATES = /*[[${commentOptions}]]*/ [];
function openGradeEditor(btn) {
var all = JSON.parse(btn.getAttribute('data-comments') || '[]');
var list = all[parseInt(btn.getAttribute('data-ci') || '0', 10)] || [];
document.getElementById('grade-student').value = btn.getAttribute('data-student');
document.getElementById('grade-lab').value = btn.getAttribute('data-lab');
document.getElementById('grade-who').value = btn.getAttribute('data-student-name');
document.getElementById('grade-title').textContent = btn.getAttribute('data-lab-name') + ' — ' + btn.getAttribute('data-student-name');
var sc = document.getElementById('grade-score');
sc.value = btn.getAttribute('data-score') || '';
var box = document.getElementById('cs-rows');
box.innerHTML = '';
list.forEach(function (c) { csRow(c.text, c.tone); });
if (!list.length) csRow('', 'neutral');
openModal('modal-grade');
}
function csRow(text, tone) {
var box = document.getElementById('cs-rows');
var row = document.createElement('div');
row.className = 'cs-row';
var inp = document.createElement('input');
inp.className = 'input cs-text'; inp.placeholder = 'Комментарий (подскажем частые)';
inp.setAttribute('list', 'cs-list');
inp.value = text;
inp.addEventListener('input', csFilter);
var sel = document.createElement('select');
sel.className = 'input cs-tone'
[['positive', '+'], ['neutral', 'o'], ['negative', '−']].forEach(function (o) {
var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op);
});
sel.value = tone || 'neutral';
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm cs-del'; rm.textContent = '✕';
rm.addEventListener('click', function () { row.remove(); });
row.appendChild(inp); row.appendChild(sel); row.appendChild(rm);
box.appendChild(row);
}
function csFilter(ta) {
var list = document.getElementById('cs-list');
list.innerHTML = '';
var q = (ta.target.value || '').toLowerCase().trim();
CS_TEMPLATES
.filter(function (t) { return !q || t.text.toLowerCase().indexOf(q) !== -1; })
.slice(0, 8)
.forEach(function (t) {
var op = document.createElement('option');
op.value = t.text;
list.appendChild(op);
});
}
function csSerialize() {
var out = [];
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
var text = r.querySelector('.cs-text').value.trim();
if (text) out.push({ text: text, tone: r.querySelector('.cs-tone').value });
});
document.getElementById('grade-comments').value = JSON.stringify(out);
return true;
}
</script>
</div>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -8,6 +8,9 @@
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1> <h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<div class="flex gap-2 text-sm"> <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="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
<span th:each="c : ${gradeComments}" class="chip"
th:classappend="${c.tone == 'positive'} ? chip-pos : (${c.tone == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.text}">коммент</span>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span> <span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div> </div>
</div> </div>