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 3b71415..e40f68e 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 @@ -22,6 +22,7 @@ import ru.ysu.lms.web.api.GradeSetPayload import ru.ysu.lms.web.api.GroupDetailDto 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.downloadSubmissionAsAdmin import ru.ysu.lms.web.api.getLabAssignments @@ -30,6 +31,7 @@ import ru.ysu.lms.web.api.groupDetail import ru.ysu.lms.web.api.groupPublications import ru.ysu.lms.web.api.importGroup import ru.ysu.lms.web.api.labDefenses +import ru.ysu.lms.web.api.labDetail import ru.ysu.lms.web.api.labSubmissions import ru.ysu.lms.web.api.listGrades import ru.ysu.lms.web.api.listGroups @@ -156,6 +158,7 @@ class AdminGroupController( labNumber = lab.number, labTitle = lab.title, variantNumber = va?.variantNumber, + variantId = va?.variantId, variantManual = va?.manual == true, score = gradeBy["${s.id}|${lab.id}"], published = publishedLabs.contains(lab.id), @@ -189,8 +192,7 @@ class AdminGroupController( ) } - val candidates = runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList()) - fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, publishedLabs)) + fillGroupModel(model, groupViewModel(t, id, GroupRenderData(group, labs, rows, publishedLabs, submissionsBy))) return "admin/group" } @@ -199,12 +201,31 @@ class AdminGroupController( id: String, ): Pair> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" } + private data class GroupRenderData( + val group: GroupDetailDto, + val labs: List, + val rows: List, + val publishedLabs: Set, + val submissionsBy: Map>, + ) + + private fun groupViewModel( + token: String, + id: String, + data: GroupRenderData, + ): GroupViewModel { + val submissionCounts = data.labs.associate { it.id to (data.submissionsBy[it]?.size ?: 0) } + val candidates = runCatching { api.groupCandidates(token, id) }.getOrDefault(emptyList()) + return GroupViewModel(data.group, data.labs, candidates, data.rows, data.publishedLabs, submissionCounts) + } + private data class GroupViewModel( val group: GroupDetailDto, val labs: List, val candidates: List, val rows: List, val publishedLabs: Set, + val submissionCounts: Map, ) private fun fillGroupModel( @@ -216,6 +237,7 @@ class AdminGroupController( model.addAttribute("candidates", view.candidates) model.addAttribute("studentGrades", view.rows) model.addAttribute("publishedLabIds", view.publishedLabs) + model.addAttribute("labSubmissionCounts", view.submissionCounts) } @PostMapping("/groups/{id}/grades") @@ -235,6 +257,26 @@ class AdminGroupController( "redirect:/admin/groups/$id" } + /** Уровни и варианты лабораторной для модалки назначения варианта. */ + @GetMapping("/labs/{labId}/schema") + @ResponseBody + fun labSchema( + @PathVariable labId: String, + session: HttpSession, + ): Map { + val lab = api.labDetail(token(session), labId) + return mapOf( + "levels" to + lab.levels.map { level -> + mapOf( + "id" to level.id, + "title" to level.title, + "variants" to level.variants.map { v -> mapOf("id" to v.id, "number" to v.number) }, + ) + }, + ) + } + /** Подсказки ранее введённых комментариев: поиск по подстроке, частые — выше. */ @GetMapping("/comment-templates") @ResponseBody diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 912d744..8606943 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -42,6 +42,7 @@ data class GroupLabCell( val labNumber: Int = 0, val labTitle: String = "", val variantNumber: String? = null, + val variantId: String? = null, val variantManual: Boolean = false, val score: String? = null, val published: Boolean = false, diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index ebf1e66..030b49f 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -321,6 +321,9 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos .cell-btn.dim { color: var(--c-text-muted); } .cell-btn.warn { color: #d97706; } .cell-btn.ok { color: #16a34a; } +.cell-num { font-size: 0.72rem; opacity: 0.75; } +.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); } +.defense-cell-timer { font-variant-numeric: tabular-nums; } .lab-head-actions { display: flex; gap: 0.2rem; } .mini-btn { display: inline-flex; align-items: center; justify-content: center; @@ -333,8 +336,9 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos .mini-btn.warn { color: #d97706; } /* Увеличенный диалог (оценка + комментарии) */ -dialog.modal.modal-lg { width: min(56rem, calc(100vw - 2rem)); } -dialog.modal.modal-lg .modal-body { max-height: 78vh; } +dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); } +dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; } +.mini-btn svg, .cell-btn svg { width: 1rem; height: 1rem; } .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; } @@ -376,7 +380,8 @@ dialog.modal.modal-lg .modal-body { max-height: 78vh; } .font-medium { font-weight: 500; } .scroll-x { overflow-x: auto; } .matrix { border-collapse: separate; border-spacing: 0; } -.matrix th, .matrix td { vertical-align: top; } +.matrix th { vertical-align: top; } +.matrix td { vertical-align: middle; } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.student-cell { min-width: 12rem; } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 7cd68e2..1b67c8b 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -1,5 +1,11 @@ function $id(id) { return document.getElementById(id); } +function fmtSeconds(v) { + var m = Math.floor(v / 60); + var s = v % 60; + return m + ':' + (s < 10 ? '0' : '') + s; +} + /* ---------- Оценка + комментарии ---------- */ function openGradeEditor(btn) { @@ -58,7 +64,16 @@ function csRow(text, tone) { attachCommentAutocomplete(inp); } -/* ---------- Автодополнение комментариев ---------- * +function csSerialize() { + var out = []; + document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) { + var t = r.querySelector('.cs-text').value.trim(); + if (t) out.push({ text: t, tone: r.querySelector('.cs-tone').value }); + }); + $id('grade-comments').value = JSON.stringify(out); +} + +/* ---------- Автодополнение комментариев ---------- * При фокусе/вводе запрашивает ранее введённые комментарии (подстрока, без регистра), * отсортированные по частоте; выбор подставляет текст и «тон». */ @@ -131,17 +146,19 @@ function attachCommentAutocomplete(input) { }); } -function csSerialize() { - var out = []; - document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) { - var t = r.querySelector('.cs-text').value.trim(); - if (t) out.push({ text: t, tone: r.querySelector('.cs-tone').value }); - }); - $id('grade-comments').value = JSON.stringify(out); -} - /* ---------- Индивидуальное задание (защита) ---------- */ +function defenseStatusText(status, code, seconds) { + if (status === 'waiting') { + return code + ? 'Задание назначено, код выдан — ждём входа студента.' + : 'Задание назначено, код ещё не выдан.'; + } + if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.'; + if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.'; + return ''; +} + /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */ function openDefense(btn) { var form = $id('defense-form'); @@ -155,15 +172,20 @@ function openDefense(btn) { var session = btn.getAttribute('data-session') || ''; var status = btn.getAttribute('data-status') || ''; + var code = btn.getAttribute('data-code') || ''; + var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10); var actions = $id('defense-actions'); + var statusEl = $id('defense-status'); if (session) { $id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code'; $id('defense-reset-form').action = '/admin/defenses/' + session + '/reset'; - if ($id('defense-action-lab')) $id('defense-action-lab').value = lab; - if ($id('defense-reset-lab')) $id('defense-reset-lab').value = lab; + $id('defense-action-lab').value = lab; + $id('defense-reset-lab').value = lab; $id('defense-reset-form').hidden = status !== 'ready'; - if (actions) actions.hidden = false; - } else if (actions) { + if (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds); + actions.hidden = false; + } else { + if (statusEl) statusEl.textContent = ''; actions.hidden = true; } @@ -171,21 +193,73 @@ function openDefense(btn) { if (d && d.showModal) d.showModal(); } -function showDefenseFromModal() { - var el = $id('defense-text-body'); - if (el) el.textContent = $id('defense-md').value || '(пусто)'; - var title = $id('defense-text-title'); - if (title) { - var s = $id('defense-student-select'); - var l = $id('defense-lab-select'); - var who = s && s.selectedOptions[0] ? s.selectedOptions[0].textContent : ''; - var lab = l && l.selectedOptions[0] ? l.selectedOptions[0].textContent : ''; - title.textContent = who + ' — ' + lab; - } - var d = $id('modal-defense-text'); +/* ---------- Назначение варианта (вручную или случайно из уровня) ---------- */ + +function openVariant(btn) { + var student = btn.getAttribute('data-student') || ''; + var lab = btn.getAttribute('data-lab') || ''; + var who = btn.getAttribute('data-student-name') || ''; + var labName = btn.getAttribute('data-lab-name') || ''; + var currentVariant = btn.getAttribute('data-variant') || ''; + + $id('variant-who').textContent = who + ' — ' + labName; + $id('variant-random-student').value = student; + $id('variant-random-lab').value = lab; + $id('variant-pin-student').value = student; + $id('variant-pin-lab').value = lab; + + var levelSel = $id('variant-level'); + levelSel.innerHTML = ''; + fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema') + .then(function (r) { return r.ok ? r.json() : { levels: [] }; }) + .then(function (data) { + (data.levels || []).forEach(function (lvl, i) { + var opt = document.createElement('option'); + opt.value = lvl.id; + opt.textContent = lvl.title || ('Уровень ' + (i + 1)); + opt.__variants = lvl.variants || []; + levelSel.appendChild(opt); + }); + levelSel.onchange = function () { fillVariantSelect(currentVariant); }; + fillVariantSelect(currentVariant); + }); + + var d = $id('modal-variant'); if (d && d.showModal) d.showModal(); } +function fillVariantSelect(currentVariant) { + var levelSel = $id('variant-level'); + var variantSel = $id('variant-variant'); + variantSel.innerHTML = ''; + var opt = levelSel.selectedOptions[0]; + var variants = (opt && opt.__variants) || []; + variants.forEach(function (v) { + var o = document.createElement('option'); + o.value = v.id; + o.textContent = 'Вариант ' + v.number; + if (currentVariant && v.id === currentVariant) o.selected = true; + variantSel.appendChild(o); + }); + $id('variant-random-level').value = levelSel.value || ''; + $id('variant-pin-id').value = variantSel.value || ''; + variantSel.onchange = function () { $id('variant-pin-id').value = variantSel.value || ''; }; +} + +/* ---------- Таймеры защиты в ячейках ---------- */ + +function tickDefenseCells() { + document.querySelectorAll('.defense-cell-timer').forEach(function (el) { + var left = parseInt(el.getAttribute('data-seconds') || '0', 10); + var tick = setInterval(function () { + left -= 1; + if (left <= 0) { clearInterval(tick); el.textContent = '0:00'; return; } + el.textContent = fmtSeconds(left); + }, 1000); + el.textContent = fmtSeconds(left); + }); +} + /* ---------- Матрица: фокус на лабораторной ---------- */ function focusLab() { @@ -201,4 +275,7 @@ document.addEventListener('submit', function (ev) { if (ev.target && ev.target.id === 'grade-form') csSerialize(); }, true); -window.addEventListener('DOMContentLoaded', function () { focusLab(); }); +window.addEventListener('DOMContentLoaded', function () { + focusLab(); + tickDefenseCells(); +}); diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 8473ef9..01e8bc2 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -11,14 +11,8 @@
- - - - Скачать все работы - Логины XLSX - CSV + Скачать пароли +
@@ -27,17 +21,12 @@
-
-

- Ячейка: «№» — вариант, «🛡» — защита (клик — задание и код), «↓» — сдача, «✎» — оценка. - Раздача вариантов — на странице лабораторной. -

Опубликованных лабораторных пока нет.
@@ -57,11 +46,14 @@ th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}"> + th:title="${publishedLabIds.contains(lab.id) ? 'Оценки опубликованы — нажмите, чтобы скрыть' : 'Показать оценки и комментарии студентам'}"> + + + ⤓ + th:title="|Скачать решения по ЛР ${lab.number}|">⤓0
@@ -78,18 +70,27 @@
- Вариант — +
↓ + th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓ ↓ + th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'"> + 🛡 + нет + ожид. + код + идёт + готов + +
+ + + -
- - - - - + @@ -240,7 +266,20 @@ - - - - +