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