Phase 7: deployment, data import, UI hardening and security fixes
Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers
Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
selected students (checkboxes)
Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
from group page; temporary-password lock (no navigation until changed) and
persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
chapters tree+assets); duplicate lab numbers rejected; variant ordering
and date defaults honored
Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
@@ -47,7 +47,13 @@ a:hover { color: var(--c-text-link-hover); }
|
||||
/* Layout */
|
||||
.page { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); }
|
||||
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; }
|
||||
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; }
|
||||
@media (max-width: 640px) {
|
||||
.page-header-inner { padding: 0.5rem 0.75rem; }
|
||||
.nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; }
|
||||
.nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
|
||||
.nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
.page-main { flex: 1; width: 100%; max-width: 1152px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); }
|
||||
|
||||
@@ -57,6 +63,12 @@ a:hover { color: var(--c-text-link-hover); }
|
||||
.nav-link:hover { color: var(--c-text-link-hover); }
|
||||
.nav-sep { color: var(--c-text-muted); }
|
||||
.logo { font-size: 1.125rem; font-weight: 600; color: var(--c-text); text-decoration: none; }
|
||||
.nav-user { font-weight: 500; }
|
||||
.theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; }
|
||||
.theme-icon { width: 1.05rem; height: 1.05rem; display: block; }
|
||||
.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.breadcrumb .nav-sep { user-select: none; }
|
||||
.check-list { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; max-height: 12rem; overflow-y: auto; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@@ -244,3 +256,76 @@ details.sub-acc > summary::-webkit-details-marker { display: none; }
|
||||
.sub-arrow { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; margin-right: 6px; vertical-align: 2px;}
|
||||
details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); }
|
||||
.sub-actions { margin: 0 0 2px 18px; }
|
||||
|
||||
/* ---- Матрица группы и карточки защиты ---- */
|
||||
.m-0 { margin: 0; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.text-strong { font-weight: 600; color: var(--c-text); }
|
||||
.text-right { text-align: right; }
|
||||
.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.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; }
|
||||
.lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
|
||||
.cell-line { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; text-align: left; }
|
||||
.cell-label { color: var(--c-text-muted); min-width: 3.5rem; }
|
||||
.cell-link { font-size: 0.8125rem; }
|
||||
.cell-edit { padding: 0 0.4rem; }
|
||||
.cell-grade-line { font-weight: 500; }
|
||||
.defense-cell-actions { display: flex; gap: 0.35rem; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.th-w-sm { width: 4rem; }
|
||||
.th-w-md { width: 12rem; }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
.preview { max-width: 34rem; }
|
||||
.md-preview { max-width: 22rem; }
|
||||
|
||||
/* ---- Комментарии к оценке (ЛК студента) ---- */
|
||||
.review-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; }
|
||||
.review-col h3 { margin-bottom: 0.35rem; }
|
||||
.review-list { margin: 0; padding-left: 1.15rem; }
|
||||
.review-list li { margin: 0.15rem 0; }
|
||||
.tone-positive-text { color: #166534; }
|
||||
.tone-negative-text { color: #991b1b; }
|
||||
[data-theme="dark"] .tone-positive-text { color: #6ee7b7; }
|
||||
[data-theme="dark"] .tone-negative-text { color: #fca5a5; }
|
||||
.tone-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
|
||||
.pre-wrap { white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* Редактируемая матрица: закреплённый столбец и компактные селекты */
|
||||
.matrix-sticky { position: sticky; left: 0; z-index: 2; background: var(--c-surface); }
|
||||
.matrix thead .matrix-sticky { z-index: 3; }
|
||||
.level-form { margin: 0; }
|
||||
.level-select {
|
||||
width: auto; min-width: 6rem; max-width: 9.5rem;
|
||||
padding: 0.15rem 1.5rem 0.15rem 0.4rem; font-size: 0.75rem;
|
||||
}
|
||||
.cell-level { justify-content: center; }
|
||||
.pin-hint { font-size: 0.75rem; }
|
||||
|
||||
/* Компактные ячейки матрицы группы */
|
||||
.btn-xs { padding: 0.05rem 0.4rem; font-size: 0.7rem; line-height: 1.5; }
|
||||
.badge-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; }
|
||||
.chip-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; }
|
||||
.cell-top { display: flex; align-items: center; justify-content: center; gap: 0.25rem; }
|
||||
.cell-chips { display: flex; flex-wrap: wrap; gap: 0.18rem; justify-content: center; margin-top: 0.25rem; }
|
||||
.cell-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.15rem;
|
||||
font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--c-border); border-radius: 99px;
|
||||
background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer;
|
||||
text-decoration: none; font-family: inherit;
|
||||
}
|
||||
.cell-chip:hover { border-color: var(--c-border-strong, var(--c-border)); filter: brightness(0.97); }
|
||||
.chip-dim { color: var(--c-text-muted); }
|
||||
.chip-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; }
|
||||
.chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; }
|
||||
.chip-link:hover { color: var(--c-text-link); }
|
||||
[data-theme="dark"] .chip-warn { color: #fcd34d; background: rgba(146, 64, 14, 0.25); border-color: rgba(146, 64, 14, 0.5); }
|
||||
[data-theme="dark"] .chip-ok { color: #86efac; background: rgba(22, 101, 52, 0.25); border-color: rgba(22, 101, 52, 0.5); }
|
||||
.lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.actions-cell .inline-form { display: inline-block; }
|
||||
|
||||
10
web/src/main/resources/static/css/hljs-github-dark.css
Normal file
10
web/src/main/resources/static/css/hljs-github-dark.css
Normal file
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||
10
web/src/main/resources/static/css/hljs-github.css
Normal file
10
web/src/main/resources/static/css/hljs-github.css
Normal file
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
116
web/src/main/resources/static/js/admin-group.js
Normal file
116
web/src/main/resources/static/js/admin-group.js
Normal file
@@ -0,0 +1,116 @@
|
||||
function $id(id) { return document.getElementById(id); }
|
||||
|
||||
/* ---------- Оценка + комментарии ---------- */
|
||||
|
||||
function openGradeEditor(btn) {
|
||||
var all = [];
|
||||
try { all = JSON.parse(btn.getAttribute('data-comments') || '[]'); } catch (e) { all = []; }
|
||||
var idx = parseInt(btn.getAttribute('data-ci') || '0', 10);
|
||||
var list = Array.isArray(all[idx]) ? all[idx] : [];
|
||||
|
||||
$id('grade-student').value = btn.getAttribute('data-student') || '';
|
||||
$id('grade-lab').value = btn.getAttribute('data-lab') || '';
|
||||
$id('grade-who').value = btn.getAttribute('data-student-name') || '';
|
||||
var title = $id('grade-title');
|
||||
if (title) title.textContent = (btn.getAttribute('data-lab-name') || '') + ' — ' + (btn.getAttribute('data-student-name') || '');
|
||||
$id('grade-score').value = btn.getAttribute('data-score') || '';
|
||||
|
||||
var box = $id('cs-rows');
|
||||
box.innerHTML = '';
|
||||
list.forEach(function (c) { csRow(c.text, c.tone); });
|
||||
if (!list.length) csRow('', 'neutral');
|
||||
|
||||
var d = $id('modal-grade');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function csRow(text, tone) {
|
||||
var box = $id('cs-rows');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'cs-row';
|
||||
|
||||
var inp = document.createElement('input');
|
||||
inp.className = 'input cs-text';
|
||||
inp.placeholder = 'Комментарий';
|
||||
inp.value = text || '';
|
||||
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'input cs-tone';
|
||||
[['positive', '+ позитивный'], ['negative', '— негативный'], ['neutral', '○ нейтральный']].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-secondary btn-sm';
|
||||
rm.textContent = 'удалить';
|
||||
rm.onclick = function () { row.remove(); };
|
||||
|
||||
row.appendChild(inp);
|
||||
row.appendChild(sel);
|
||||
row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
}
|
||||
|
||||
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 openDefenseForm(btn) {
|
||||
var form = $id('defense-form');
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
var student = btn.getAttribute('data-student') || '';
|
||||
var lab = btn.getAttribute('data-lab') || '';
|
||||
if (student) $id('defense-student-select').value = student;
|
||||
if (lab) $id('defense-lab-select').value = lab;
|
||||
$id('defense-md').value = btn.getAttribute('data-md') || '';
|
||||
var d = $id('modal-defense');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function showDefenseText(btn) {
|
||||
var el = $id('defense-text-body');
|
||||
if (el) el.textContent = btn.getAttribute('data-md') || '(пусто)';
|
||||
var title = $id('defense-text-title');
|
||||
if (title) title.textContent = (btn.getAttribute('data-student-name') || '') + ' — ' + (btn.getAttribute('data-lab-name') || '');
|
||||
var d = $id('modal-defense-text');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function filterDefense() {
|
||||
var s = $id('defense-lab-filter');
|
||||
if (!s) return;
|
||||
var labId = s.value;
|
||||
document.querySelectorAll('#defense-table tr[data-lab]').forEach(function (tr) {
|
||||
tr.style.display = (!labId || tr.getAttribute('data-lab') === labId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Матрица: фокус на лабораторной и закрепление варианта ---------- */
|
||||
|
||||
function focusLab() {
|
||||
var s = $id('lab-focus');
|
||||
if (!s) return;
|
||||
var labId = s.value;
|
||||
document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab]').forEach(function (el) {
|
||||
el.style.display = (!labId || el.getAttribute('data-lab') === labId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function (ev) {
|
||||
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () { filterDefense(); focusLab(); });
|
||||
36
web/src/main/resources/static/js/admin-lab.js
Normal file
36
web/src/main/resources/static/js/admin-lab.js
Normal file
@@ -0,0 +1,36 @@
|
||||
function toggleAllStudents(cb) {
|
||||
document.querySelectorAll('.pick-student').forEach(function (c) { c.checked = cb.checked; });
|
||||
}
|
||||
|
||||
function collectPicked(form) {
|
||||
var picked = Array.prototype.slice.call(document.querySelectorAll('.pick-student:checked'));
|
||||
if (!picked.length) {
|
||||
alert('Отметьте хотя бы одного студента.');
|
||||
return false;
|
||||
}
|
||||
picked.forEach(function (c) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'studentIds';
|
||||
input.value = c.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function confirmOccupied(sel) {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
|
||||
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
|
||||
if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) {
|
||||
if (sel.dataset.prev !== undefined) sel.value = sel.dataset.prev;
|
||||
return;
|
||||
}
|
||||
}
|
||||
sel.dataset.prev = sel.value;
|
||||
if (sel.form) sel.form.submit();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.pick-variant').forEach(function (s) { s.dataset.prev = s.value; });
|
||||
});
|
||||
23
web/src/main/resources/static/js/defense-timer.js
Normal file
23
web/src/main/resources/static/js/defense-timer.js
Normal file
@@ -0,0 +1,23 @@
|
||||
document.querySelectorAll('.defense-timer').forEach(function (el) {
|
||||
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
|
||||
function render(v) {
|
||||
var m = Math.floor(v / 60), s = v % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
render(left);
|
||||
var tick = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
|
||||
render(left);
|
||||
}, 1000);
|
||||
});
|
||||
document.querySelectorAll('.ru-datetime').forEach(function (el) {
|
||||
try {
|
||||
var iso = (el.textContent || '').trim();
|
||||
if (iso && !el.getAttribute('data-done')) {
|
||||
var d = new Date(iso);
|
||||
el.textContent = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short', timeZone: 'Europe/Moscow' }).format(d);
|
||||
el.setAttribute('data-done', '1');
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
1244
web/src/main/resources/static/js/highlight.min.js
vendored
Normal file
1244
web/src/main/resources/static/js/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user