diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index 4a780e1..bfebb3f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -146,6 +146,13 @@ data class CommentOut( val tone: String, ) +/** Подсказка комментария: текст, тон и текущее число использований. */ +data class CommentSuggestion( + val text: String, + val tone: String, + val count: Long, +) + data class GradeResponse( val studentId: String, val labId: String, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt index 211763d..2bdc579 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt @@ -74,11 +74,38 @@ class GradeService( lid: UUID, ): List = gradeComments.findByStudentIdAndLabId(sid, lid) + /** + * Подсказки комментариев: поиск по подстроке, выше — чаще используемые сейчас. + * Число использований считается по актуальным оценкам, а не по накопительному [CommentTemplate.useCount]. + */ @Transactional(readOnly = true) - fun suggestComments(query: String): List = - templates - .findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) - .map { CommentOut(it.text, it.tone) } + fun suggestComments(query: String): List { + val found = templates.findByTextContainingIgnoreCase(query.trim()) + val counts = liveCounts(found.map { it.text }) + return found + .map { it to (counts[it.text] ?: 0L) } + .sortedWith(compareByDescending> { 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): Map { + if (texts.isEmpty()) return emptyMap() + return gradeComments + .findByTextIn(texts) + .groupingBy { it.text } + .eachCount() + .mapValues { it.value.toLong() } + } @Transactional(readOnly = true) fun myComments(studentId: UUID): List = gradeComments.findByStudentId(studentId) @@ -98,4 +125,8 @@ class GradeService( ) } } + + private companion object { + const val SUGGEST_LIMIT = 30 + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index 0e12359..3b46da1 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -4,6 +4,7 @@ import jakarta.validation.Valid import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal 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.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -121,5 +122,13 @@ class ProgressController( @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun suggestComments( @RequestParam q: String, - ): List = grades.suggestComments(q) + ): List = grades.suggestComments(q) + + @DeleteMapping("/comment-templates") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun deleteCommentTemplate( + @RequestParam text: String, + ) { + grades.deleteCommentTemplate(text) + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt index 5233036..b49ac89 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/repo/CommentRepositories.kt @@ -8,7 +8,9 @@ import java.util.UUID interface CommentTemplateRepository : JpaRepository { fun findByText(text: String): CommentTemplate? - fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List + fun findByTextContainingIgnoreCase(text: String): List + + fun deleteByText(text: String) } interface GradeCommentRepository : JpaRepository { @@ -23,4 +25,8 @@ interface GradeCommentRepository : JpaRepository { ) fun findByStudentId(studentId: UUID): List + + fun findByTextIn(texts: Collection): List + + fun countByText(text: String): Long } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 9318ab4..fb62664 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -24,6 +24,7 @@ import ru.ysu.lms.web.api.LabSummaryDto import ru.ysu.lms.web.api.StudentCandidateDto import ru.ysu.lms.web.api.SubmissionDto 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.getLabAssignments import ru.ysu.lms.web.api.groupCandidates @@ -297,7 +298,24 @@ class AdminGroupController( fun commentTemplates( @RequestParam(required = false, defaultValue = "") q: String, session: HttpSession, - ): List> = api.suggestComments(token(session), q).map { mapOf("text" to it.text, "tone" to it.tone) } + ): List> = + 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 = + 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") fun downloadGroupSubmissions( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt index ec736c4..15eafdb 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt @@ -93,8 +93,8 @@ fun ApiClient.setGrade( fun ApiClient.suggestComments( token: String, query: String, -): List { - val type = object : ParameterizedTypeReference>() {} +): List { + val type = object : ParameterizedTypeReference>() {} return call { client .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 = myGrades(token, null) fun ApiClient.myGrades( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 272407a..1046787 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -245,6 +245,13 @@ data class CommentDto( val tone: String = "neutral", ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class CommentSuggestionDto( + val text: String = "", + val tone: String = "neutral", + val count: Long = 0, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class SubmissionDto( val id: String = "", diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 2eca458..f1fa385 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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); 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-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); } /* Ячейка матрицы: вариант и кнопки действий под ним; кнопки в шапке ЛР */ .cell-stack { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index c639969..9940639 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -100,10 +100,42 @@ function attachCommentAutocomplete(input) { } list.innerHTML = ''; items.forEach(function (item) { + var count = parseInt(item.count || 0, 10) || 0; var row = document.createElement('div'); 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) { + if (event.target.closest('.ac-del')) return; event.preventDefault(); input.value = item.text; 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) { if (status === 'waiting') { return code