Admin group: variant modal, cell badges/counters, header cleanup, bigger modal
This commit is contained in:
@@ -22,6 +22,7 @@ import ru.ysu.lms.web.api.GradeSetPayload
|
|||||||
import ru.ysu.lms.web.api.GroupDetailDto
|
import ru.ysu.lms.web.api.GroupDetailDto
|
||||||
import ru.ysu.lms.web.api.LabSummaryDto
|
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.createGroup
|
import ru.ysu.lms.web.api.createGroup
|
||||||
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
|
||||||
@@ -30,6 +31,7 @@ import ru.ysu.lms.web.api.groupDetail
|
|||||||
import ru.ysu.lms.web.api.groupPublications
|
import ru.ysu.lms.web.api.groupPublications
|
||||||
import ru.ysu.lms.web.api.importGroup
|
import ru.ysu.lms.web.api.importGroup
|
||||||
import ru.ysu.lms.web.api.labDefenses
|
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.labSubmissions
|
||||||
import ru.ysu.lms.web.api.listGrades
|
import ru.ysu.lms.web.api.listGrades
|
||||||
import ru.ysu.lms.web.api.listGroups
|
import ru.ysu.lms.web.api.listGroups
|
||||||
@@ -156,6 +158,7 @@ class AdminGroupController(
|
|||||||
labNumber = lab.number,
|
labNumber = lab.number,
|
||||||
labTitle = lab.title,
|
labTitle = lab.title,
|
||||||
variantNumber = va?.variantNumber,
|
variantNumber = va?.variantNumber,
|
||||||
|
variantId = va?.variantId,
|
||||||
variantManual = va?.manual == true,
|
variantManual = va?.manual == true,
|
||||||
score = gradeBy["${s.id}|${lab.id}"],
|
score = gradeBy["${s.id}|${lab.id}"],
|
||||||
published = publishedLabs.contains(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(t, id, GroupRenderData(group, labs, rows, publishedLabs, submissionsBy)))
|
||||||
fillGroupModel(model, GroupViewModel(group, labs, candidates, rows, publishedLabs))
|
|
||||||
return "admin/group"
|
return "admin/group"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,12 +201,31 @@ class AdminGroupController(
|
|||||||
id: String,
|
id: String,
|
||||||
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
|
): Pair<GroupDetailDto, List<LabSummaryDto>> = api.groupDetail(token, id) to api.listLabs(token).filter { it.status == "published" }
|
||||||
|
|
||||||
|
private data class GroupRenderData(
|
||||||
|
val group: GroupDetailDto,
|
||||||
|
val labs: List<LabSummaryDto>,
|
||||||
|
val rows: List<GroupStudentRow>,
|
||||||
|
val publishedLabs: Set<String>,
|
||||||
|
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
private data class GroupViewModel(
|
||||||
val group: GroupDetailDto,
|
val group: GroupDetailDto,
|
||||||
val labs: List<LabSummaryDto>,
|
val labs: List<LabSummaryDto>,
|
||||||
val candidates: List<StudentCandidateDto>,
|
val candidates: List<StudentCandidateDto>,
|
||||||
val rows: List<GroupStudentRow>,
|
val rows: List<GroupStudentRow>,
|
||||||
val publishedLabs: Set<String>,
|
val publishedLabs: Set<String>,
|
||||||
|
val submissionCounts: Map<String, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fillGroupModel(
|
private fun fillGroupModel(
|
||||||
@@ -216,6 +237,7 @@ class AdminGroupController(
|
|||||||
model.addAttribute("candidates", view.candidates)
|
model.addAttribute("candidates", view.candidates)
|
||||||
model.addAttribute("studentGrades", view.rows)
|
model.addAttribute("studentGrades", view.rows)
|
||||||
model.addAttribute("publishedLabIds", view.publishedLabs)
|
model.addAttribute("publishedLabIds", view.publishedLabs)
|
||||||
|
model.addAttribute("labSubmissionCounts", view.submissionCounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/{id}/grades")
|
@PostMapping("/groups/{id}/grades")
|
||||||
@@ -235,6 +257,26 @@ class AdminGroupController(
|
|||||||
"redirect:/admin/groups/$id"
|
"redirect:/admin/groups/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Уровни и варианты лабораторной для модалки назначения варианта. */
|
||||||
|
@GetMapping("/labs/{labId}/schema")
|
||||||
|
@ResponseBody
|
||||||
|
fun labSchema(
|
||||||
|
@PathVariable labId: String,
|
||||||
|
session: HttpSession,
|
||||||
|
): Map<String, Any> {
|
||||||
|
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")
|
@GetMapping("/comment-templates")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ data class GroupLabCell(
|
|||||||
val labNumber: Int = 0,
|
val labNumber: Int = 0,
|
||||||
val labTitle: String = "",
|
val labTitle: String = "",
|
||||||
val variantNumber: String? = null,
|
val variantNumber: String? = null,
|
||||||
|
val variantId: String? = null,
|
||||||
val variantManual: Boolean = false,
|
val variantManual: Boolean = false,
|
||||||
val score: String? = null,
|
val score: String? = null,
|
||||||
val published: Boolean = false,
|
val published: Boolean = false,
|
||||||
|
|||||||
@@ -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.dim { color: var(--c-text-muted); }
|
||||||
.cell-btn.warn { color: #d97706; }
|
.cell-btn.warn { color: #d97706; }
|
||||||
.cell-btn.ok { color: #16a34a; }
|
.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; }
|
.lab-head-actions { display: flex; gap: 0.2rem; }
|
||||||
.mini-btn {
|
.mini-btn {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
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; }
|
.mini-btn.warn { color: #d97706; }
|
||||||
|
|
||||||
/* Увеличенный диалог (оценка + комментарии) */
|
/* Увеличенный диалог (оценка + комментарии) */
|
||||||
dialog.modal.modal-lg { width: min(56rem, calc(100vw - 2rem)); }
|
dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); }
|
||||||
dialog.modal.modal-lg .modal-body { max-height: 78vh; }
|
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 { 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-pos { background: #16803c; color: #fff; }
|
||||||
@@ -376,7 +380,8 @@ dialog.modal.modal-lg .modal-body { max-height: 78vh; }
|
|||||||
.font-medium { font-weight: 500; }
|
.font-medium { font-weight: 500; }
|
||||||
.scroll-x { overflow-x: auto; }
|
.scroll-x { overflow-x: auto; }
|
||||||
.matrix { border-collapse: separate; border-spacing: 0; }
|
.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 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.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||||
.matrix td.student-cell { min-width: 12rem; }
|
.matrix td.student-cell { min-width: 12rem; }
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
function $id(id) { return document.getElementById(id); }
|
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) {
|
function openGradeEditor(btn) {
|
||||||
@@ -58,7 +64,16 @@ function csRow(text, tone) {
|
|||||||
attachCommentAutocomplete(inp);
|
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-* (тогда это «назначить новое»). */
|
/** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
|
||||||
function openDefense(btn) {
|
function openDefense(btn) {
|
||||||
var form = $id('defense-form');
|
var form = $id('defense-form');
|
||||||
@@ -155,15 +172,20 @@ function openDefense(btn) {
|
|||||||
|
|
||||||
var session = btn.getAttribute('data-session') || '';
|
var session = btn.getAttribute('data-session') || '';
|
||||||
var status = btn.getAttribute('data-status') || '';
|
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 actions = $id('defense-actions');
|
||||||
|
var statusEl = $id('defense-status');
|
||||||
if (session) {
|
if (session) {
|
||||||
$id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code';
|
$id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code';
|
||||||
$id('defense-reset-form').action = '/admin/defenses/' + session + '/reset';
|
$id('defense-reset-form').action = '/admin/defenses/' + session + '/reset';
|
||||||
if ($id('defense-action-lab')) $id('defense-action-lab').value = lab;
|
$id('defense-action-lab').value = lab;
|
||||||
if ($id('defense-reset-lab')) $id('defense-reset-lab').value = lab;
|
$id('defense-reset-lab').value = lab;
|
||||||
$id('defense-reset-form').hidden = status !== 'ready';
|
$id('defense-reset-form').hidden = status !== 'ready';
|
||||||
if (actions) actions.hidden = false;
|
if (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds);
|
||||||
} else if (actions) {
|
actions.hidden = false;
|
||||||
|
} else {
|
||||||
|
if (statusEl) statusEl.textContent = '';
|
||||||
actions.hidden = true;
|
actions.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,21 +193,73 @@ function openDefense(btn) {
|
|||||||
if (d && d.showModal) d.showModal();
|
if (d && d.showModal) d.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDefenseFromModal() {
|
/* ---------- Назначение варианта (вручную или случайно из уровня) ---------- */
|
||||||
var el = $id('defense-text-body');
|
|
||||||
if (el) el.textContent = $id('defense-md').value || '(пусто)';
|
function openVariant(btn) {
|
||||||
var title = $id('defense-text-title');
|
var student = btn.getAttribute('data-student') || '';
|
||||||
if (title) {
|
var lab = btn.getAttribute('data-lab') || '';
|
||||||
var s = $id('defense-student-select');
|
var who = btn.getAttribute('data-student-name') || '';
|
||||||
var l = $id('defense-lab-select');
|
var labName = btn.getAttribute('data-lab-name') || '';
|
||||||
var who = s && s.selectedOptions[0] ? s.selectedOptions[0].textContent : '';
|
var currentVariant = btn.getAttribute('data-variant') || '';
|
||||||
var lab = l && l.selectedOptions[0] ? l.selectedOptions[0].textContent : '';
|
|
||||||
title.textContent = who + ' — ' + lab;
|
$id('variant-who').textContent = who + ' — ' + labName;
|
||||||
}
|
$id('variant-random-student').value = student;
|
||||||
var d = $id('modal-defense-text');
|
$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();
|
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() {
|
function focusLab() {
|
||||||
@@ -201,4 +275,7 @@ document.addEventListener('submit', function (ev) {
|
|||||||
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', function () { focusLab(); });
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
focusLab();
|
||||||
|
tickDefenseCells();
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,14 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
|
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
|
||||||
<button class="btn btn-secondary" type="button"
|
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
|
||||||
th:disabled="${#lists.isEmpty(candidates)}"
|
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт заданий (CSV)</button>
|
||||||
onclick="openModal('modal-enroll-student')">Добавить существующего</button>
|
|
||||||
<button class="btn btn-secondary" type="button" onclick="openDefense(this)">Назначить задание</button>
|
|
||||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
|
|
||||||
<a class="btn btn-secondary" th:href="'/admin/groups/' + ${group.id} + '/submissions.zip'">Скачать все работы</a>
|
|
||||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Логины XLSX</a>
|
|
||||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,17 +21,12 @@
|
|||||||
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
|
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
|
||||||
<input class="input input-sm table-search" data-target="group-table"
|
<input class="input input-sm table-search" data-target="group-table"
|
||||||
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
|
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
|
||||||
<label class="label m-0" for="lab-focus">Показать:</label>
|
|
||||||
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
|
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
|
||||||
<option value="">все лабораторные</option>
|
<option value="">все лабораторные</option>
|
||||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted mb-4">
|
|
||||||
Ячейка: «№» — вариант, «🛡» — защита (клик — задание и код), «↓» — сдача, «✎» — оценка.
|
|
||||||
Раздача вариантов — на странице лабораторной.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||||||
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
|
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
|
||||||
@@ -57,11 +46,14 @@
|
|||||||
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
|
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
|
||||||
<button class="mini-btn" type="submit"
|
<button class="mini-btn" type="submit"
|
||||||
th:classappend="${publishedLabIds.contains(lab.id)} ? 'ok' : 'warn'"
|
th:classappend="${publishedLabIds.contains(lab.id)} ? 'ok' : 'warn'"
|
||||||
th:title="${publishedLabIds.contains(lab.id) ? 'Оценки опубликованы — нажмите, чтобы скрыть' : 'Показать оценки и комментарии студентам'}">👁</button>
|
th:title="${publishedLabIds.contains(lab.id) ? 'Оценки опубликованы — нажмите, чтобы скрыть' : 'Показать оценки и комментарии студентам'}">
|
||||||
|
<svg th:if="${publishedLabIds.contains(lab.id)}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg th:unless="${publishedLabIds.contains(lab.id)}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="mini-btn"
|
<a class="mini-btn"
|
||||||
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}"
|
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}"
|
||||||
th:title="|Скачать решения по ЛР ${lab.number}|">⤓</a>
|
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -78,18 +70,27 @@
|
|||||||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
|
||||||
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
|
||||||
<div class="cell-stack">
|
<div class="cell-stack">
|
||||||
<span class="variant-btn" th:classappend="${cell.variantManual} ? 'manual' : ''"
|
<button class="variant-btn" type="button"
|
||||||
th:title="${cell.variantManual} ? 'Вариант закреплён вручную (раздача его не тронет)' : 'Вариант выдан автоматически'"
|
th:classappend="${cell.variantManual} ? 'manual' : ''"
|
||||||
th:text="'Вариант ' + (${cell.variantNumber} ?: '—')">Вариант —</span>
|
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}"
|
||||||
|
onclick="openVariant(this)"
|
||||||
|
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button>
|
||||||
<div class="cell-btns">
|
<div class="cell-btns">
|
||||||
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение"
|
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение"
|
||||||
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓</a>
|
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓<span class="cell-num" th:text="${cell.submissionSeq}"></span></a>
|
||||||
<span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span>
|
<span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span>
|
||||||
<button class="cell-btn" type="button"
|
<button class="cell-btn" type="button"
|
||||||
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')"
|
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')"
|
||||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus}"
|
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode}"
|
||||||
onclick="openDefense(this)"
|
onclick="openDefense(this)"
|
||||||
th:title="${cell.defenseStatus == null} ? 'Назначить задание на защиту' : 'Защита: задание и действия'">🛡</button>
|
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'">
|
||||||
|
<span>🛡</span>
|
||||||
|
<span th:if="${cell.defenseStatus == null}">нет</span>
|
||||||
|
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}">ожид.</span>
|
||||||
|
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}">код</span>
|
||||||
|
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer" th:attr="data-seconds=${cell.defenseSecondsLeft}">идёт</span>
|
||||||
|
<span th:if="${cell.defenseStatus == 'ready'}">готов</span>
|
||||||
|
</button>
|
||||||
<button class="cell-btn" type="button"
|
<button class="cell-btn" type="button"
|
||||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"
|
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"
|
||||||
onclick="openGradeEditor(this)"
|
onclick="openGradeEditor(this)"
|
||||||
@@ -149,6 +150,39 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Диалог: индивидуальное задание (защита) -->
|
<!-- Диалог: индивидуальное задание (защита) -->
|
||||||
|
<!-- Диалог: назначение варианта -->
|
||||||
|
<dialog id="modal-variant" class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">Назначить вариант</h2>
|
||||||
|
<button class="modal-close" type="button" onclick="closeModal('modal-variant')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body form-stack">
|
||||||
|
<p class="text-sm text-muted" id="variant-who">—</p>
|
||||||
|
<div><label class="label" for="variant-level">Уровень</label>
|
||||||
|
<select class="input" id="variant-level"></select></div>
|
||||||
|
<div><label class="label" for="variant-variant">Вариант</label>
|
||||||
|
<select class="input" id="variant-variant"></select></div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<form class="inline-form" id="variant-random-form" method="post"
|
||||||
|
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="studentId" id="variant-random-student">
|
||||||
|
<input type="hidden" name="labId" id="variant-random-lab">
|
||||||
|
<input type="hidden" name="labLevelId" id="variant-random-level">
|
||||||
|
<button class="btn btn-primary" type="submit">Выдать случайный из уровня</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-form" id="variant-pin-form" method="post"
|
||||||
|
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<input type="hidden" name="studentId" id="variant-pin-student">
|
||||||
|
<input type="hidden" name="labId" id="variant-pin-lab">
|
||||||
|
<input type="hidden" name="variantId" id="variant-pin-id">
|
||||||
|
<button class="btn btn-secondary" type="submit">Закрепить выбранный</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="modal-defense" class="modal">
|
<dialog id="modal-defense" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Индивидуальное задание</h2>
|
<h2 class="modal-title">Индивидуальное задание</h2>
|
||||||
@@ -173,37 +207,29 @@
|
|||||||
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
|
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
|
||||||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="defense-actions" class="mt-3" hidden>
|
<div id="defense-actions" class="mt-4" hidden>
|
||||||
|
<p id="defense-status" class="text-sm text-muted mb-2"></p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<form class="inline-form" id="defense-issue-form" method="post">
|
<form class="inline-form" id="defense-issue-form" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="labId" id="defense-action-lab">
|
<input type="hidden" name="labId" id="defense-action-lab">
|
||||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||||
<button class="btn btn-secondary btn-sm" type="submit">выдать код</button>
|
<button class="btn btn-primary" type="submit">Выдать код</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-secondary btn-sm" type="button" onclick="showDefenseFromModal()">текст задания</button>
|
|
||||||
<form class="inline-form" id="defense-reset-form" method="post"
|
<form class="inline-form" id="defense-reset-form" method="post"
|
||||||
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
|
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="labId" id="defense-reset-lab">
|
<input type="hidden" name="labId" id="defense-reset-lab">
|
||||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||||
<button class="btn btn-danger btn-sm" type="submit">начать заново</button>
|
<button class="btn btn-danger" type="submit">Начать заново</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Диалог просмотра текста задания -->
|
|
||||||
<dialog id="modal-defense-text" class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title" id="defense-text-title">Текст задания</h2>
|
|
||||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-text')">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<pre id="defense-text-body" class="pre-wrap"></pre>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- Диалог импорта CSV -->
|
<!-- Диалог импорта CSV -->
|
||||||
<dialog id="modal-defense-import" class="modal">
|
<dialog id="modal-defense-import" class="modal">
|
||||||
@@ -240,7 +266,20 @@
|
|||||||
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
|
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
|
<form class="form-stack" id="enroll-form" method="post"
|
||||||
|
th:action="@{/admin/groups/{gid}/students/enroll(gid=${group.id})}">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<div><label class="label" for="enroll-select">Существующий студент</label>
|
||||||
|
<select class="input" id="enroll-select" name="studentId" required>
|
||||||
|
<option value="">— выберите студента —</option>
|
||||||
|
<option th:each="c : ${candidates}" th:value="${c.id}"
|
||||||
|
th:text="${c.fullName} + ' (' + ${c.studentNo} + ')' + (${c.currentGroupName} ? ' — из ' + ${c.currentGroupName} : ' — без группы')">
|
||||||
|
</option>
|
||||||
|
</select></div>
|
||||||
|
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p>
|
||||||
|
<button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
|
||||||
|
</form>
|
||||||
|
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
|
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
|
||||||
@@ -256,34 +295,13 @@
|
|||||||
<div><label class="label" for="stMiddle">Отчество</label>
|
<div><label class="label" for="stMiddle">Отчество</label>
|
||||||
<input class="input" id="stMiddle" name="middleName"></div>
|
<input class="input" id="stMiddle" name="middleName"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Пароль будет сгенерирован автоматически.</p>
|
<p class="hint">Новому студенту пароль будет сгенерирован автоматически.</p>
|
||||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
<button class="btn btn-primary" type="submit">Создать нового</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="modal-enroll-student" class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title">Добавить существующего студента</h2>
|
|
||||||
<button class="modal-close" type="button" onclick="closeModal('modal-enroll-student')">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form class="form-stack" id="enroll-form" method="post"
|
|
||||||
th:action="@{/admin/groups/{gid}/students/enroll(gid=${group.id})}">
|
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
|
||||||
<div><label class="label" for="enroll-select">Студент</label>
|
|
||||||
<select class="input" id="enroll-select" name="studentId" required>
|
|
||||||
<option value="">— выберите студента —</option>
|
|
||||||
<option th:each="c : ${candidates}" th:value="${c.id}"
|
|
||||||
th:text="${c.fullName} + ' (' + ${c.studentNo} + ')' + (${c.currentGroupName} ? ' — из ' + ${c.currentGroupName} : ' — без группы')">
|
|
||||||
</option>
|
|
||||||
</select></div>
|
|
||||||
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.
|
|
||||||
Отчисление не удаляет аккаунт, студент сможет войти и после перевода.</p>
|
|
||||||
<button class="btn btn-primary" type="submit">Зачислить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<div th:if="${createdStudent}" class="card import-card toast">
|
<div th:if="${createdStudent}" class="card import-card toast">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user