Grade comments: show live usage count and allow deleting unused templates
This commit is contained in:
@@ -146,6 +146,13 @@ data class CommentOut(
|
|||||||
val tone: String,
|
val tone: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Подсказка комментария: текст, тон и текущее число использований. */
|
||||||
|
data class CommentSuggestion(
|
||||||
|
val text: String,
|
||||||
|
val tone: String,
|
||||||
|
val count: Long,
|
||||||
|
)
|
||||||
|
|
||||||
data class GradeResponse(
|
data class GradeResponse(
|
||||||
val studentId: String,
|
val studentId: String,
|
||||||
val labId: String,
|
val labId: String,
|
||||||
|
|||||||
@@ -74,11 +74,38 @@ class GradeService(
|
|||||||
lid: UUID,
|
lid: UUID,
|
||||||
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подсказки комментариев: поиск по подстроке, выше — чаще используемые сейчас.
|
||||||
|
* Число использований считается по актуальным оценкам, а не по накопительному [CommentTemplate.useCount].
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun suggestComments(query: String): List<CommentOut> =
|
fun suggestComments(query: String): List<CommentSuggestion> {
|
||||||
templates
|
val found = templates.findByTextContainingIgnoreCase(query.trim())
|
||||||
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
|
val counts = liveCounts(found.map { it.text })
|
||||||
.map { CommentOut(it.text, it.tone) }
|
return found
|
||||||
|
.map { it to (counts[it.text] ?: 0L) }
|
||||||
|
.sortedWith(compareByDescending<Pair<CommentTemplate, Long>> { it.second }.thenByDescending { it.first.useCount })
|
||||||
|
.take(SUGGEST_LIMIT)
|
||||||
|
.map { CommentSuggestion(it.first.text, it.first.tone, it.second) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет шаблон комментария, только если он нигде не проставлен. */
|
||||||
|
@Transactional
|
||||||
|
fun deleteCommentTemplate(text: String) {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
val inUse = gradeComments.countByText(trimmed)
|
||||||
|
if (inUse > 0) throw BadRequestException("Комментарий используется $inUse раз(а) — удалить нельзя")
|
||||||
|
templates.deleteByText(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun liveCounts(texts: List<String>): Map<String, Long> {
|
||||||
|
if (texts.isEmpty()) return emptyMap()
|
||||||
|
return gradeComments
|
||||||
|
.findByTextIn(texts)
|
||||||
|
.groupingBy { it.text }
|
||||||
|
.eachCount()
|
||||||
|
.mapValues { it.value.toLong() }
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
|
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
|
||||||
@@ -98,4 +125,8 @@ class GradeService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SUGGEST_LIMIT = 30
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import jakarta.validation.Valid
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -121,5 +122,13 @@ class ProgressController(
|
|||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun suggestComments(
|
fun suggestComments(
|
||||||
@RequestParam q: String,
|
@RequestParam q: String,
|
||||||
): List<CommentOut> = grades.suggestComments(q)
|
): List<CommentSuggestion> = grades.suggestComments(q)
|
||||||
|
|
||||||
|
@DeleteMapping("/comment-templates")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun deleteCommentTemplate(
|
||||||
|
@RequestParam text: String,
|
||||||
|
) {
|
||||||
|
grades.deleteCommentTemplate(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import java.util.UUID
|
|||||||
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
|
||||||
fun findByText(text: String): CommentTemplate?
|
fun findByText(text: String): CommentTemplate?
|
||||||
|
|
||||||
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
|
fun findByTextContainingIgnoreCase(text: String): List<CommentTemplate>
|
||||||
|
|
||||||
|
fun deleteByText(text: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
||||||
@@ -23,4 +25,8 @@ interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun findByStudentId(studentId: UUID): List<GradeComment>
|
fun findByStudentId(studentId: UUID): List<GradeComment>
|
||||||
|
|
||||||
|
fun findByTextIn(texts: Collection<String>): List<GradeComment>
|
||||||
|
|
||||||
|
fun countByText(text: String): Long
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import ru.ysu.lms.web.api.LabSummaryDto
|
|||||||
import ru.ysu.lms.web.api.StudentCandidateDto
|
import ru.ysu.lms.web.api.StudentCandidateDto
|
||||||
import ru.ysu.lms.web.api.SubmissionDto
|
import ru.ysu.lms.web.api.SubmissionDto
|
||||||
import ru.ysu.lms.web.api.createGroup
|
import ru.ysu.lms.web.api.createGroup
|
||||||
|
import ru.ysu.lms.web.api.deleteCommentTemplate
|
||||||
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
import ru.ysu.lms.web.api.downloadSubmissionAsAdmin
|
||||||
import ru.ysu.lms.web.api.getLabAssignments
|
import ru.ysu.lms.web.api.getLabAssignments
|
||||||
import ru.ysu.lms.web.api.groupCandidates
|
import ru.ysu.lms.web.api.groupCandidates
|
||||||
@@ -297,7 +298,24 @@ class AdminGroupController(
|
|||||||
fun commentTemplates(
|
fun commentTemplates(
|
||||||
@RequestParam(required = false, defaultValue = "") q: String,
|
@RequestParam(required = false, defaultValue = "") q: String,
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
): List<Map<String, String>> = api.suggestComments(token(session), q).map { mapOf("text" to it.text, "tone" to it.tone) }
|
): List<Map<String, Any>> =
|
||||||
|
api.suggestComments(token(session), q).map {
|
||||||
|
mapOf("text" to it.text, "tone" to it.tone, "count" to it.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет шаблон комментария, только если он нигде не проставлен. */
|
||||||
|
@PostMapping("/comment-templates/delete")
|
||||||
|
@ResponseBody
|
||||||
|
fun deleteCommentTemplate(
|
||||||
|
@RequestParam text: String,
|
||||||
|
session: HttpSession,
|
||||||
|
): Map<String, Any> =
|
||||||
|
try {
|
||||||
|
api.deleteCommentTemplate(token(session), text)
|
||||||
|
mapOf("ok" to true)
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
mapOf("ok" to false, "error" to ex.message)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/groups/{id}/submissions.zip")
|
@GetMapping("/groups/{id}/submissions.zip")
|
||||||
fun downloadGroupSubmissions(
|
fun downloadGroupSubmissions(
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ fun ApiClient.setGrade(
|
|||||||
fun ApiClient.suggestComments(
|
fun ApiClient.suggestComments(
|
||||||
token: String,
|
token: String,
|
||||||
query: String,
|
query: String,
|
||||||
): List<CommentDto> {
|
): List<CommentSuggestionDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<CommentDto>>() {}
|
val type = object : ParameterizedTypeReference<List<CommentSuggestionDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
client
|
client
|
||||||
.get()
|
.get()
|
||||||
@@ -105,6 +105,20 @@ fun ApiClient.suggestComments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ApiClient.deleteCommentTemplate(
|
||||||
|
token: String,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.delete()
|
||||||
|
.uri("/api/v1/comment-templates?text={t}", text)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun ApiClient.myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
fun ApiClient.myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
||||||
|
|
||||||
fun ApiClient.myGrades(
|
fun ApiClient.myGrades(
|
||||||
|
|||||||
@@ -245,6 +245,13 @@ data class CommentDto(
|
|||||||
val tone: String = "neutral",
|
val tone: String = "neutral",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class CommentSuggestionDto(
|
||||||
|
val text: String = "",
|
||||||
|
val tone: String = "neutral",
|
||||||
|
val count: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class SubmissionDto(
|
data class SubmissionDto(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
|
|||||||
@@ -312,8 +312,13 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
|
|||||||
background: var(--c-surface); border: 1px solid var(--c-border);
|
background: var(--c-surface); border: 1px solid var(--c-border);
|
||||||
border-radius: 0.4rem; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
border-radius: 0.4rem; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
.ac-item { padding: 0.4rem 0.6rem; cursor: pointer; font-size: 0.875rem; }
|
.ac-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; cursor: pointer; font-size: 0.875rem; }
|
||||||
.ac-item:hover, .ac-item.active { background: var(--c-bg); }
|
.ac-item:hover, .ac-item.active { background: var(--c-bg); }
|
||||||
|
.ac-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ac-count { flex: 0 0 auto; background: var(--c-border); color: var(--c-text-muted); }
|
||||||
|
.ac-del { flex: 0 0 auto; border: none; background: transparent; color: var(--c-text-muted); cursor: pointer; padding: 0 0.25rem; border-radius: 0.25rem; font-size: 0.75rem; line-height: 1.4; }
|
||||||
|
.ac-del:not(:disabled):hover { color: #dc2626; background: var(--c-bg); }
|
||||||
|
.ac-del:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
.ac-empty { padding: 0.4rem 0.6rem; font-size: 0.875rem; color: var(--c-text-muted); }
|
.ac-empty { padding: 0.4rem 0.6rem; font-size: 0.875rem; color: var(--c-text-muted); }
|
||||||
/* Ячейка матрицы: вариант и кнопки действий под ним; кнопки в шапке ЛР */
|
/* Ячейка матрицы: вариант и кнопки действий под ним; кнопки в шапке ЛР */
|
||||||
.cell-stack { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; }
|
.cell-stack { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; }
|
||||||
|
|||||||
@@ -100,10 +100,42 @@ function attachCommentAutocomplete(input) {
|
|||||||
}
|
}
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
|
var count = parseInt(item.count || 0, 10) || 0;
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'ac-item';
|
row.className = 'ac-item';
|
||||||
row.textContent = item.text;
|
|
||||||
|
var text = document.createElement('span');
|
||||||
|
text.className = 'ac-text';
|
||||||
|
text.textContent = item.text;
|
||||||
|
row.appendChild(text);
|
||||||
|
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge badge-xs ac-count';
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.title = 'Использований: ' + count;
|
||||||
|
row.appendChild(badge);
|
||||||
|
|
||||||
|
var del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'ac-del';
|
||||||
|
del.textContent = '✕';
|
||||||
|
del.disabled = count > 0;
|
||||||
|
del.title = count > 0
|
||||||
|
? 'Нельзя удалить: комментарий используется ' + count + ' раз(а)'
|
||||||
|
: 'Удалить комментарий из подсказок';
|
||||||
|
del.addEventListener('mousedown', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
del.addEventListener('click', function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (count > 0) return;
|
||||||
|
removeCommentTemplate(item.text, load);
|
||||||
|
});
|
||||||
|
row.appendChild(del);
|
||||||
|
|
||||||
row.addEventListener('mousedown', function (event) {
|
row.addEventListener('mousedown', function (event) {
|
||||||
|
if (event.target.closest('.ac-del')) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
input.value = item.text;
|
input.value = item.text;
|
||||||
var tone = input.parentNode.querySelector('.cs-tone');
|
var tone = input.parentNode.querySelector('.cs-tone');
|
||||||
@@ -146,8 +178,54 @@ function attachCommentAutocomplete(input) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Индивидуальное задание (защита) ---------- */
|
/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */
|
||||||
|
|
||||||
|
function showToast(text, kind) {
|
||||||
|
var host = $id('toast-host');
|
||||||
|
if (!host) return;
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
|
||||||
|
toast.setAttribute('data-toast', '');
|
||||||
|
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'toast-text';
|
||||||
|
span.textContent = text;
|
||||||
|
toast.appendChild(span);
|
||||||
|
|
||||||
|
var close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.className = 'toast-close';
|
||||||
|
close.textContent = '✕';
|
||||||
|
close.addEventListener('click', function () { toast.remove(); });
|
||||||
|
toast.appendChild(close);
|
||||||
|
|
||||||
|
host.appendChild(toast);
|
||||||
|
setTimeout(function () { toast.remove(); }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCommentTemplate(text, done) {
|
||||||
|
var form = $id('grade-form');
|
||||||
|
var csrf = form ? form.querySelector('input[name="_csrf"]') : null;
|
||||||
|
var body = '_csrf=' + encodeURIComponent(csrf ? csrf.value : '') +
|
||||||
|
'&text=' + encodeURIComponent(text);
|
||||||
|
fetch('/admin/comment-templates/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { ok: false }; })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res && res.ok) {
|
||||||
|
showToast('Комментарий удалён из подсказок.');
|
||||||
|
if (done) done();
|
||||||
|
} else {
|
||||||
|
showToast((res && res.error) || 'Не удалось удалить комментарий.', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { showToast('Не удалось удалить комментарий.', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Индивидуальное задание (защита) ---------- */
|
||||||
function defenseStatusText(status, code, seconds) {
|
function defenseStatusText(status, code, seconds) {
|
||||||
if (status === 'waiting') {
|
if (status === 'waiting') {
|
||||||
return code
|
return code
|
||||||
|
|||||||
Reference in New Issue
Block a user