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:
@@ -20,7 +20,7 @@ spring:
|
||||
- id: progress
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
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:
|
||||
endpoints:
|
||||
|
||||
@@ -128,12 +128,17 @@ data class GradeSetRequest(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
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(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val score: BigDecimal,
|
||||
val comments: List<CommentOut> = emptyList(),
|
||||
)
|
||||
|
||||
data class SubmissionDto(
|
||||
|
||||
@@ -84,6 +84,10 @@ class ProgressController(
|
||||
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
|
||||
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}")
|
||||
fun submit(
|
||||
@PathVariable labId: UUID,
|
||||
|
||||
@@ -3,10 +3,14 @@ package ru.ysu.lms.progress
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
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.LabGradeKey
|
||||
import ru.ysu.lms.progress.domain.StudentLabLevel
|
||||
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.StudentLabLevelRepository
|
||||
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
|
||||
@@ -19,6 +23,8 @@ class ProgressService(
|
||||
private val levels: StudentLabLevelRepository,
|
||||
private val assignments: VariantAssignmentRepository,
|
||||
private val grades: LabGradeRepository,
|
||||
private val templates: CommentTemplateRepository,
|
||||
private val gradeComments: GradeCommentRepository,
|
||||
private val academic: AcademicClient,
|
||||
private val identity: IdentityClient,
|
||||
) {
|
||||
@@ -180,16 +186,60 @@ class ProgressService(
|
||||
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("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) }
|
||||
grade.score = request.score
|
||||
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)
|
||||
fun listGrades(): List<GradeResponse> =
|
||||
grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) }
|
||||
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||
|
||||
@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)
|
||||
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
@@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets
|
||||
class AdminController(
|
||||
private val api: ApiClient,
|
||||
private val markdown: ru.ysu.lms.markdown.MarkdownRenderer,
|
||||
private val mapper: tools.jackson.databind.ObjectMapper,
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
@@ -119,6 +120,7 @@ class AdminController(
|
||||
val labs = api.listLabs(t)
|
||||
val grades = api.listGrades(t)
|
||||
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 scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] }
|
||||
val nums = scores.mapNotNull { it?.toBigDecimalOrNull() }
|
||||
@@ -128,11 +130,17 @@ class AdminController(
|
||||
studentNo = s.studentNo,
|
||||
scores = scores,
|
||||
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("labs", labs)
|
||||
model.addAttribute("studentGrades", studentGrades)
|
||||
model.addAttribute("commentOptions", api.suggestComments(t, ""))
|
||||
return "admin/group"
|
||||
}
|
||||
|
||||
@@ -142,10 +150,12 @@ class AdminController(
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam score: String,
|
||||
@RequestParam(required = false) commentsJson: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): 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:/admin/groups/$id"
|
||||
} catch (ex: ApiException) {
|
||||
@@ -624,6 +634,24 @@ class AdminController(
|
||||
"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 =
|
||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
?: throw ApiException(401, "Сессия истекла, войдите заново")
|
||||
|
||||
@@ -31,4 +31,6 @@ data class AdminStudentGrades(
|
||||
val studentNo: String,
|
||||
val scores: List<String?>,
|
||||
val average: Double?,
|
||||
val comments: List<List<Pair<String, String>>>,
|
||||
val commentsJson: String,
|
||||
)
|
||||
@@ -306,6 +306,15 @@ class ApiClient(
|
||||
.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> {
|
||||
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
||||
return call {
|
||||
|
||||
@@ -210,8 +210,12 @@ data class GradeDto(
|
||||
val studentId: String = "",
|
||||
val labId: 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)
|
||||
data class SubmissionDto(
|
||||
val id: String = "",
|
||||
@@ -226,8 +230,11 @@ data class GradeSetPayload(
|
||||
val studentId: String,
|
||||
val labId: String,
|
||||
val score: String,
|
||||
val comments: List<CommentPayload> = emptyList(),
|
||||
)
|
||||
|
||||
data class CommentPayload(val text: String, val tone: String)
|
||||
|
||||
data class LabCreatePayload(
|
||||
val number: Int,
|
||||
val title: String,
|
||||
|
||||
@@ -84,7 +84,9 @@ class StudentController(
|
||||
model.addAttribute("assignment", assignment)
|
||||
model.addAttribute("statementHtml", assignment?.statementHtml)
|
||||
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("canSubmit", canSubmit(lab.deadlineAt))
|
||||
val defense = api.myDefense(t, id)
|
||||
|
||||
@@ -217,4 +217,17 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
|
||||
.form-card { padding: 1.5rem; }
|
||||
|
||||
/* 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 */
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -62,14 +62,21 @@
|
||||
<tr th:each="row : ${studentGrades}">
|
||||
<td th:text="${row.fullName}">—</td>
|
||||
<td th:each="score, iter : ${row.scores}">
|
||||
<form class="inline-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
|
||||
<input type="hidden" name="studentId" th:value="${row.studentId}">
|
||||
<input type="hidden" name="labId" th:value="${labs[iter.index].id}">
|
||||
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5"
|
||||
th:value="${score}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<div>
|
||||
<div class="score-cell-root">
|
||||
<form class="inline-form">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
|
||||
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>
|
||||
</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 class="text-center font-semibold">
|
||||
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
|
||||
@@ -144,6 +151,98 @@
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -8,6 +8,9 @@
|
||||
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user