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

@@ -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 */

View File

@@ -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>

View File

@@ -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>