Compare commits

...

10 Commits

Author SHA1 Message Date
5ced01f70d Lab page: per-variant create/edit modals, rendered conditions, inline lab edit; student page: compact code field, big score, defense wording, header highlight; toasts tinted; p margin 2026-10-02 20:25:36 +03:00
7b1e6043c2 app.js: drop native confirm fallback 2026-10-02 18:42:07 +03:00
cdf8586904 No inline JS/styles in templates: app.js delegation (data-action/data-modal/data-confirm/data-auto-submit), B3 confirm/alert dialogs, theme-init file 2026-10-02 18:41:12 +03:00
5a1985b142 Learn pages: icon actions (eye/pencil/hand/trash), centered accordion arrows; toasts bottom-center in B3 style 2026-10-02 17:31:06 +03:00
c2f7235df6 Group page: B3 fidelity (cell-main/cell-sub, defense code in cell), class-based eye/focus, utilities; drop import button 2026-10-02 17:02:58 +03:00
ece700d91b Group page: B3 layout (page-head, toolbar, matrix), neutral header with accent logo dot 2026-10-02 16:37:00 +03:00
3c54830f48 Style: align last tweaks with B3 bordeaux design (neutral header, outline buttons, 8/10px radii) 2026-10-02 16:00:14 +03:00
556fdf3b5e Redesign B3 bordeaux + finish lab-page rework
- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
2026-10-02 15:41:16 +03:00
7a781fea33 Admin: users page with search and full cascade delete (identity + progress + files) 2026-10-02 11:53:01 +03:00
f7a0dc9d3b Defense: sync admin/student timers from server every 10s; toasts with ready reason; live eye counter 2026-10-02 01:21:32 +03:00
86 changed files with 4414 additions and 1088 deletions

View File

@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS — демо стиля А: университетская классика</title>
<!-- Если нет интернета, сработают запасные шрифты: Georgia и системный гротеск -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&family=PT+Sans:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f3ee;
--surface: #fdfcf9;
--text: #21201c;
--muted: #6f6a5e;
--border: #ddd7c9;
--border-strong: #b9b2a0;
--accent: #1e5138;
--accent-hover: #173f2c;
--accent-soft: #e8efe9;
--danger: #8f2d3c;
--ok: #1e5138;
--warn: #8a5a12;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 "PT Sans", "Segoe UI", system-ui, sans-serif;
}
.demo-note {
background: #2c2a24; color: #f0ece2; text-align: center;
font-size: 12.5px; padding: 6px 12px; letter-spacing: .04em;
}
/* ── Шапка ─────────────────────────────────────────────── */
.page-header {
background: var(--surface); border-bottom: 2px solid var(--accent);
}
.header-inner {
max-width: 1180px; margin: 0 auto; padding: 14px 24px;
display: flex; align-items: center; gap: 24px;
}
.logo {
font-family: "PT Serif", Georgia, serif; font-size: 22px; font-weight: 700;
color: var(--accent); text-decoration: none; letter-spacing: .02em;
}
.nav { display: flex; gap: 18px; flex: 1; }
.nav a { color: var(--muted); text-decoration: none; font-size: 14px; padding-bottom: 2px; }
.nav a.active { color: var(--text); border-bottom: 2px solid var(--accent); }
.nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 12px; font-size: 14px; }
/* ── Основная часть ────────────────────────────────────── */
.page-main { max-width: 1180px; margin: 0 auto; padding: 28px 24px 48px; }
.kicker {
margin: 0 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted);
}
h1 {
margin: 0 0 6px; font-family: "PT Serif", Georgia, serif;
font-size: 30px; font-weight: 700; line-height: 1.2;
}
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.meta { margin: 0; color: var(--muted); font-size: 13.5px; }
.actions { display: flex; gap: 10px; }
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
/* ── Таблица-ведомость ─────────────────────────────────── */
.matrix-wrap { background: var(--surface); border: 1px solid var(--border); }
table.matrix { width: 100%; border-collapse: collapse; }
.matrix th {
padding: 10px 14px; text-align: left;
font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
color: var(--muted); border-bottom: 1px solid var(--border-strong);
}
.matrix td { padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: top; }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix tbody tr:hover { background: #f6f3ec; }
.sticky { min-width: 190px; }
.student-name { font-weight: 700; }
.student-no { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.lab-no { display: block; color: var(--text); }
.lab-title { display: block; font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 12px; }
.cell-main { display: flex; align-items: baseline; gap: 10px; }
.variant { font-family: "PT Serif", Georgia, serif; font-weight: 700; }
.score { font-variant-numeric: tabular-nums; font-weight: 700; }
.score.muted { color: var(--muted); font-weight: 400; }
.cell-sub { margin-top: 4px; display: flex; gap: 12px; font-size: 12.5px; color: var(--muted); }
.sub-link { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
.sub-link:hover { color: var(--text); }
.st { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.st::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
.st.done { color: var(--ok); } .st.done::before { background: var(--ok); }
.st.active { color: var(--warn); } .st.active::before { background: var(--warn); }
.st.code { color: var(--muted); }
.eye { cursor: default; font-size: 13px; color: var(--border-strong); }
.eye.on { color: var(--accent); }
.ta-r { text-align: right; }
/* ── Кнопки, бейджи, поля ──────────────────────────────── */
.btn {
display: inline-block; padding: 7px 14px; font: inherit; font-size: 13.5px;
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text); border-radius: 3px; cursor: pointer; text-decoration: none;
}
.btn:hover { border-color: var(--text); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fdfcf9; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fdfcf9; }
.btn-sm { padding: 4px 9px; font-size: 12.5px; }
.input {
padding: 7px 10px; font: inherit; font-size: 13.5px; color: var(--text);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 3px;
}
.input:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
.badge {
display: inline-block; padding: 2px 8px; font-size: 11px; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase;
border: 1px solid currentColor; border-radius: 2px;
}
.badge.warn { color: var(--warn); background: #f7f1e3; }
.badge.ok { color: var(--ok); background: var(--accent-soft); }
.notice { border: 1px solid var(--border-strong); border-left: 3px solid var(--accent); background: var(--surface); padding: 10px 14px; font-size: 13.5px; }
/* ── Секция элементов ──────────────────────────────────── */
.components { margin-top: 36px; border-top: 2px solid var(--border-strong); padding-top: 18px; }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin: 12px 0; }
.page-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 20px; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<div class="demo-note">Демо-шаблон «А. Университетская классика» · данные условные · тёмная тема будет в реализации</div>
<header class="page-header">
<div class="header-inner">
<a class="logo" href="#">LMS</a>
<nav class="nav">
<a href="#" class="active">Группы</a>
<a href="#">Пользователи</a>
<a href="#">Лабораторные</a>
</nav>
<div class="user">
<span>Копнин В.</span>
<button class="btn btn-sm">Выйти</button>
</div>
</div>
</header>
<main class="page-main">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1>М202-1 · Первый курс</h1>
<p class="meta">24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин</p>
</div>
<div class="actions">
<button class="btn">Скачать пароли</button>
<button class="btn btn-primary">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input" style="width:260px" placeholder="Поиск по студенту…">
<select class="input"><option>все лабораторные</option><option>ЛР 1</option><option>ЛР 2</option></select>
</div>
<div class="matrix-wrap">
<table class="matrix">
<thead>
<tr>
<th class="sticky">Студент</th>
<th><span class="lab-no">ЛР 1</span><span class="lab-title">Анализ данных</span></th>
<th><span class="lab-no">ЛР 2</span><span class="lab-title">Сортировки</span></th>
<th><span class="lab-no">ЛР 3</span><span class="lab-title">Деревья поиска</span></th>
<th>Пароль</th>
<th class="ta-r">Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky"><div class="student-name">Иванова Анна</div><div class="student-no">2025001</div></td>
<td>
<div class="cell-main"><span class="variant">В-7</span><span class="score">4.5</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 2</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-2</span><span class="score">3.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st code">защита: код 552314</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Петров Павел</div><div class="student-no">2025002</div></td>
<td>
<div class="cell-main"><span class="variant">В-1</span><span class="score">5.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 3</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-4</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 12:34</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Сидоров Алексей</div><div class="student-no">2025017</div></td>
<td>
<div class="cell-main"><span class="variant">В-5</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Кузнецова Мария</div><div class="student-no">2025021</div></td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score">4.0</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-6</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 03:07</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
</tbody>
</table>
</div>
<section class="components">
<p class="kicker">Элементы интерфейса</p>
<div class="row">
<button class="btn btn-primary">Сохранить всё</button>
<button class="btn">Отмена</button>
<button class="btn btn-danger">Удалить</button>
<span class="badge ok">published</span>
<span class="badge warn">draft</span>
</div>
<div class="row">
<input class="input" style="width:220px" value="Поле ввода">
<input class="input" style="width:120px" value="552314">
</div>
<div class="notice">Оценки опубликованы всей группе — студенты видят баллы и комментарии.</div>
</section>
</main>
<footer class="page-footer">LMS · демо-шаблон · стиль А</footer>
</body>
</html>

254
design/style-b-minimal.html Normal file
View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS — демо стиля Б: строгий минимализм</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--text: #111113;
--muted: #75757e;
--border: #e6e6e9;
--border-strong: #d4d4da;
--accent: #16161a;
--accent-hover: #000000;
--danger: #c62828;
--ok: #1a7f37;
--warn: #9a6700;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.55 "Inter", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.demo-note {
background: #111113; color: #fafafa; text-align: center;
font-size: 12px; padding: 6px 12px; letter-spacing: .05em;
}
/* ── Шапка ─────────────────────────────────────────────── */
.page-header { background: var(--surface); border-bottom: 1px solid var(--border); }
.header-inner {
max-width: 1200px; margin: 0 auto; padding: 14px 28px;
display: flex; align-items: center; gap: 28px;
}
.logo { font-size: 16px; font-weight: 700; color: var(--text); text-decoration: none; letter-spacing: -.01em; }
.nav { display: flex; gap: 20px; flex: 1; }
.nav a { color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500; }
.nav a.active { color: var(--text); }
.nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 12px; font-size: 13.5px; }
/* ── Основная часть ────────────────────────────────────── */
.page-main { max-width: 1200px; margin: 0 auto; padding: 40px 28px 56px; }
.kicker { margin: 0 0 6px; font-size: 12px; font-weight: 500; color: var(--muted); }
h1 { margin: 0 0 8px; font-size: 24px; font-weight: 600; letter-spacing: -.02em; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
.meta { margin: 0; color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 8px; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
/* ── Таблица-ведомость ─────────────────────────────────── */
.matrix-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
table.matrix { width: 100%; border-collapse: collapse; }
.matrix th {
padding: 12px 16px; text-align: left;
font-size: 12px; font-weight: 500; color: var(--muted);
border-bottom: 1px solid var(--border);
}
.matrix td { padding: 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix tbody tr:hover { background: #f6f6f7; }
.sticky { min-width: 200px; }
.student-name { font-weight: 600; font-size: 13.5px; }
.student-no { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.lab-no { display: block; color: var(--text); font-weight: 500; }
.lab-title { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); }
.cell-main { display: flex; align-items: baseline; gap: 12px; }
.variant { font-weight: 600; font-size: 13.5px; }
.score { font-variant-numeric: tabular-nums; font-weight: 600; }
.score.muted { color: var(--muted); font-weight: 400; }
.cell-sub { margin-top: 6px; display: flex; gap: 14px; font-size: 12px; color: var(--muted); }
.sub-link { color: var(--muted); text-decoration: none; }
.sub-link:hover { color: var(--text); text-decoration: underline; }
.st { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.st.done { color: var(--ok); } .st.done::before { background: var(--ok); }
.st.active { color: var(--warn); } .st.active::before { background: var(--warn); }
.st.code { color: var(--muted); }
.eye { cursor: default; color: var(--border-strong); font-size: 12px; }
.eye.on { color: var(--text); }
.ta-r { text-align: right; }
/* ── Кнопки, бейджи, поля ──────────────────────────────── */
.btn {
display: inline-block; padding: 8px 16px; font: inherit; font-size: 13px; font-weight: 500;
border: 1px solid transparent; background: var(--accent);
color: #ffffff; border-radius: 8px; cursor: pointer; text-decoration: none;
}
.btn:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text); }
.btn-secondary:hover { background: var(--bg); }
.btn-danger { background: transparent; color: var(--danger); }
.btn-danger:hover { background: #fbe9e9; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.input {
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px;
}
.input:focus { outline: 2px solid #11111322; border-color: var(--text); }
.badge {
display: inline-block; padding: 3px 9px; font-size: 11.5px; font-weight: 500;
border-radius: 6px; background: #f0f0f2; color: var(--muted);
}
.badge.warn { background: #fdf3d8; color: var(--warn); }
.badge.ok { background: #e2f3e7; color: var(--ok); }
.notice { border: 1px solid var(--border); background: var(--surface); border-radius: 10px; padding: 12px 16px; font-size: 13px; }
/* ── Секция элементов ──────────────────────────────────── */
.components { margin-top: 44px; border-top: 1px solid var(--border); padding-top: 24px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0; }
.page-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 24px; }
</style>
</head>
<body>
<div class="demo-note">Демо-шаблон «Б. Строгий минимализм» · данные условные · тёмная тема будет в реализации</div>
<header class="page-header">
<div class="header-inner">
<a class="logo" href="#">LMS</a>
<nav class="nav">
<a href="#" class="active">Группы</a>
<a href="#">Пользователи</a>
<a href="#">Лабораторные</a>
</nav>
<div class="user">
<span>Копнин В.</span>
<button class="btn btn-secondary btn-sm">Выйти</button>
</div>
</div>
</header>
<main class="page-main">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1>М202-1 · Первый курс</h1>
<p class="meta">24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин</p>
</div>
<div class="actions">
<button class="btn btn-secondary">Скачать пароли</button>
<button class="btn">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input" style="width:260px" placeholder="Поиск по студенту…">
<select class="input"><option>все лабораторные</option><option>ЛР 1</option><option>ЛР 2</option></select>
</div>
<div class="matrix-wrap">
<table class="matrix">
<thead>
<tr>
<th class="sticky">Студент</th>
<th><span class="lab-no">ЛР 1</span><span class="lab-title">Анализ данных</span></th>
<th><span class="lab-no">ЛР 2</span><span class="lab-title">Сортировки</span></th>
<th><span class="lab-no">ЛР 3</span><span class="lab-title">Деревья поиска</span></th>
<th>Пароль</th>
<th class="ta-r">Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky"><div class="student-name">Иванова Анна</div><div class="student-no">2025001</div></td>
<td>
<div class="cell-main"><span class="variant">В-7</span><span class="score">4.5</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 2</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-2</span><span class="score">3.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st code">защита: код 552314</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Петров Павел</div><div class="student-no">2025002</div></td>
<td>
<div class="cell-main"><span class="variant">В-1</span><span class="score">5.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 3</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-4</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 12:34</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Сидоров Алексей</div><div class="student-no">2025017</div></td>
<td>
<div class="cell-main"><span class="variant">В-5</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Кузнецова Мария</div><div class="student-no">2025021</div></td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score">4.0</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-6</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 03:07</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
</tbody>
</table>
</div>
<section class="components">
<p class="kicker">Элементы интерфейса</p>
<div class="row">
<button class="btn">Сохранить всё</button>
<button class="btn btn-secondary">Отмена</button>
<button class="btn btn-danger">Удалить</button>
<span class="badge ok">published</span>
<span class="badge warn">draft</span>
</div>
<div class="row">
<input class="input" style="width:220px" value="Поле ввода">
<input class="input" style="width:120px" value="552314">
</div>
<div class="notice">Оценки опубликованы всей группе — студенты видят баллы и комментарии.</div>
</section>
</main>
<footer class="page-footer">LMS · демо-шаблон · стиль Б</footer>
</body>
</html>

View File

@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="ru" data-accent="blue">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS — демо стиля Б2: минимализм с живой палитрой</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ─────────────────────────────────────────────────────────
База: нейтральный каркас + насыщенные ФУНКЦИОНАЛЬНЫЕ цвета.
Акцент выбирается переключателем сверху (data-accent на <html>).
───────────────────────────────────────────────────────── */
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f9fafb;
--text: #14161a;
--muted: #6b7280;
--border: #e5e7eb;
--border-strong: #d2d5db;
--ok: #15803d;
--ok-soft: #e8f6ee;
--warn: #b45309;
--warn-soft: #fdf3e3;
--danger: #c62828;
--danger-soft: #fdecec;
--info: #0369a1;
--info-soft: #e6f3fa;
--radius: 10px;
--radius-sm: 8px;
}
/* Акценты */
:root[data-accent="blue"] { --accent: #2563eb; --accent-hover: #1d4fd7; --accent-soft: #eaf1ff; }
:root[data-accent="emerald"] { --accent: #0e7a55; --accent-hover: #0a5e42; --accent-soft: #e6f5ef; }
:root[data-accent="violet"] { --accent: #6d28d9; --accent-hover: #5b21b6; --accent-soft: #f1ebfe; }
:root[data-accent="graphite"]{ --accent: #23252b; --accent-hover: #000000; --accent-soft: #eef0f3; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.55 "Inter", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.demo-note {
background: #14161a; color: #fafafa; text-align: center;
font-size: 12px; padding: 6px 12px;
}
/* ── Переключатель акцента ─────────────────────────────── */
.accent-bar {
background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 8px 12px; font-size: 12.5px; color: var(--muted); flex-wrap: wrap;
}
.swatch {
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent;
cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center;
}
.swatch span { width: 12px; height: 12px; border-radius: 50%; display: block; }
.swatch.active { border-color: var(--text); }
.sw-blue span { background: #2563eb; }
.sw-emerald span { background: #0e7a55; }
.sw-violet span { background: #6d28d9; }
.sw-graphite span{ background: #23252b; }
/* ── Шапка ─────────────────────────────────────────────── */
.page-header { background: var(--surface); border-bottom: 1px solid var(--border); }
.header-inner {
max-width: 1200px; margin: 0 auto; padding: 14px 28px;
display: flex; align-items: center; gap: 28px; flex-wrap: wrap;
}
.logo { font-size: 16px; font-weight: 700; color: var(--text); text-decoration: none; }
.logo b { color: var(--accent); }
.nav { display: flex; gap: 4px; flex: 1; flex-wrap: wrap; }
.nav a {
color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 6px 12px; border-radius: var(--radius-sm);
}
.nav a.active { color: var(--accent); background: var(--accent-soft); }
.nav a:hover { color: var(--text); background: var(--surface-2); }
.user { display: flex; align-items: center; gap: 12px; font-size: 13.5px; }
/* ── Основная часть ────────────────────────────────────── */
.page-main { max-width: 1200px; margin: 0 auto; padding: 36px 28px 56px; }
.kicker { margin: 0 0 6px; font-size: 12px; font-weight: 500; color: var(--muted); }
h1 { margin: 0 0 8px; font-size: 24px; font-weight: 700; letter-spacing: -.02em; }
h1 small { color: var(--accent); font-weight: 700; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
.meta { margin: 0; color: var(--muted); font-size: 13px; }
.meta b { color: var(--ok); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
/* ── Таблица-ведомость ─────────────────────────────────── */
.matrix-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
table.matrix { width: 100%; border-collapse: collapse; }
.matrix th {
padding: 12px 16px; text-align: left; background: var(--surface-2);
font-size: 12px; font-weight: 500; color: var(--muted);
border-bottom: 1px solid var(--border); white-space: nowrap;
}
.matrix td { padding: 14px 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix tbody tr:hover { background: #f5f8ff33; }
.sticky { min-width: 200px; }
.student-name { font-weight: 600; font-size: 13.5px; }
.student-no { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.lab-no { display: block; color: var(--text); font-weight: 500; }
.lab-title { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); }
.cell-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.variant { font-weight: 600; font-size: 13.5px; color: var(--accent); }
.score { font-variant-numeric: tabular-nums; font-weight: 700; }
.score.good { color: var(--ok); }
.score.mid { color: var(--warn); }
.score.muted { color: var(--muted); font-weight: 400; }
.cell-sub { margin-top: 6px; display: flex; gap: 14px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.sub-link { color: var(--info); text-decoration: none; font-weight: 500; }
.sub-link:hover { text-decoration: underline; }
.st { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.st.done { color: var(--ok); } .st.done::before { background: var(--ok); }
.st.active {
color: var(--warn); background: var(--warn-soft);
padding: 2px 8px; border-radius: 99px; font-weight: 600;
}
.st.active::before { background: var(--warn); }
.st.code { color: var(--muted); }
.eye { cursor: default; color: var(--border-strong); font-size: 12px; }
.eye.on { color: var(--ok); }
.ta-r { text-align: right; }
/* ── Кнопки, бейджи, поля ──────────────────────────────── */
.btn {
display: inline-block; padding: 8px 16px; font: inherit; font-size: 13px; font-weight: 600;
border: 1px solid transparent; background: var(--accent);
color: #fff; border-radius: var(--radius-sm); cursor: pointer; text-decoration: none;
}
.btn:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text); }
.btn-secondary:hover { background: var(--surface-2); }
.btn-danger { background: var(--surface); color: var(--danger); border-color: #efc4c4; }
.btn-danger:hover { background: var(--danger-soft); }
.btn-accent-soft { background: var(--accent-soft); color: var(--accent); }
.btn-accent-soft:hover { background: var(--accent); color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.input {
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
.input:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
/* Бейдж — только индикатор статуса, некликабельный, всегда по-русски */
.badge {
display: inline-block; padding: 3px 10px; font-size: 11.5px; font-weight: 600;
border-radius: 6px; background: var(--surface-2); color: var(--muted);
border: 1px solid var(--border); cursor: default;
}
.badge.ok { background: var(--ok-soft); color: var(--ok); border-color: #c4e5d0; }
.badge.warn { background: var(--warn-soft); color: var(--warn); border-color: #ecd3ae; }
.badge.info { background: var(--info-soft); color: var(--info); border-color: #bfdff0; }
.notice {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 16px; font-size: 13px; background: var(--surface);
}
.notice.ok { border-color: #c4e5d0; background: var(--ok-soft); color: #14532d; }
.notice.warn { border-color: #ecd3ae; background: var(--warn-soft); color: #78350f; }
.notice.err { border-color: #efc4c4; background: var(--danger-soft); color: #7f1d1d; }
.notice.info { border-color: #bfdff0; background: var(--info-soft); color: #0c4a6e; }
/* ── Секция элементов ──────────────────────────────────── */
.components { margin-top: 40px; border-top: 1px solid var(--border); padding-top: 24px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0; }
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.status-line .name { font-weight: 600; min-width: 210px; }
.page-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 24px; }
/* ── Адаптивность таблицы: три режима ─────────────────────
>1080px — полная таблица;
721–1080px — горизонтальный скролл с прилипающей колонкой «Студент»;
≤720px — карточка на каждого студента (шапка таблицы скрыта). */
.table-scroll { border-radius: 12px; }
@media (max-width: 1080px) {
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.matrix { min-width: 860px; }
th.sticky, td.sticky {
position: sticky; left: 0; z-index: 1;
background: var(--surface);
box-shadow: 8px 0 8px -8px rgba(20, 22, 26, .12);
}
thead th.sticky { background: var(--surface-2); }
tbody tr:hover td.sticky { background: #f4f6fb; }
}
@media (max-width: 720px) {
.page-main { padding: 24px 16px 40px; }
.header-inner { padding: 12px 16px; gap: 10px; }
.page-head { display: block; }
.actions { margin-top: 12px; }
.table-scroll { overflow-x: visible; }
.matrix { min-width: 0; }
.matrix thead { display: none; }
.matrix, .matrix tbody, .matrix tr, .matrix td { display: block; width: 100%; }
.matrix tr {
border: 1px solid var(--border); border-radius: 12px;
margin: 0 0 14px; background: var(--surface); overflow: hidden;
}
.matrix td { border-bottom: none; padding: 9px 16px; }
td.sticky {
position: static; box-shadow: none;
background: var(--surface-2);
border-bottom: 1px solid var(--border) !important;
padding: 12px 16px;
}
td[data-label]::before {
content: attr(data-label);
display: block; font-size: 10.5px; font-weight: 600;
letter-spacing: .07em; text-transform: uppercase;
color: var(--muted); margin-bottom: 3px;
}
.ta-r { text-align: left; }
.cell-sub { margin-top: 4px; }
}
</style>
</head>
<body>
<div class="demo-note">Демо-шаблон «Б2» · развитый минимализм: живая палитра, адаптивные таблицы, бейджи-индикаторы · сузьте окно, чтобы увидеть все три режима таблицы</div>
<div class="accent-bar">
Акцентный цвет:
<button class="swatch sw-blue active" title="Синий (рекомендую)" onclick="setAccent('blue', this)"><span></span></button>
<button class="swatch sw-emerald" title="Изумруд" onclick="setAccent('emerald', this)"><span></span></button>
<button class="swatch sw-violet" title="Фиолет" onclick="setAccent('violet', this)"><span></span></button>
<button class="swatch sw-graphite" title="Графит (как в Б1)" onclick="setAccent('graphite', this)"><span></span></button>
<span style="opacity:.6">— красный остаётся только на действиях «Удалить/Отчислить»</span>
</div>
<header class="page-header">
<div class="header-inner">
<a class="logo" href="#">LMS<b>.</b></a>
<nav class="nav">
<a href="#" class="active">Группы</a>
<a href="#">Пользователи</a>
<a href="#">Лабораторные</a>
</nav>
<div class="user">
<span>Копнин В.</span>
<button class="btn btn-secondary btn-sm">Выйти</button>
</div>
</div>
</header>
<main class="page-main">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1>М202-1 <small>· Первый курс</small></h1>
<p class="meta">24 студента · <b>3 опубликованные</b> лабораторные · длительность защиты 30 мин</p>
</div>
<div class="actions">
<button class="btn btn-secondary">Скачать пароли</button>
<button class="btn">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input" style="width:260px" placeholder="Поиск по студенту…">
<select class="input"><option>все лабораторные</option><option>ЛР 1</option><option>ЛР 2</option></select>
</div>
<div class="matrix-wrap">
<div class="table-scroll">
<table class="matrix">
<thead>
<tr>
<th class="sticky">Студент</th>
<th><span class="lab-no">ЛР 1</span><span class="lab-title">Анализ данных</span></th>
<th><span class="lab-no">ЛР 2</span><span class="lab-title">Сортировки</span></th>
<th><span class="lab-no">ЛР 3</span><span class="lab-title">Деревья поиска</span></th>
<th>Пароль</th>
<th class="ta-r">Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky"><div class="student-name">Иванова Анна</div><div class="student-no">2025001</div></td>
<td data-label="ЛР 1 · Анализ данных">
<div class="cell-main"><span class="variant">В-7</span><span class="score good">4.5</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 2</a><span class="st done">защита: готова</span></div>
</td>
<td data-label="ЛР 2 · Сортировки">
<div class="cell-main"><span class="variant">В-2</span><span class="score mid">3.0</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st code">защита: код 552314</span></div>
</td>
<td data-label="ЛР 3 · Деревья поиска">
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td data-label="Пароль"><span class="badge warn">временный</span></td>
<td class="ta-r" data-label="Действия"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Петров Павел</div><div class="student-no">2025002</div></td>
<td data-label="ЛР 1 · Анализ данных">
<div class="cell-main"><span class="variant">В-1</span><span class="score good">5.0</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 3</a><span class="st done">защита: готова</span></div>
</td>
<td data-label="ЛР 2 · Сортировки">
<div class="cell-main"><span class="variant">В-4</span><span class="score muted">—</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 12:34</span></div>
</td>
<td data-label="ЛР 3 · Деревья поиска">
<div class="cell-main"><span class="variant">В-3</span><span class="score muted">—</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td data-label="Пароль"><span class="badge ok">изменён</span></td>
<td class="ta-r" data-label="Действия"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Сидоров Алексей</div><div class="student-no">2025017</div></td>
<td data-label="ЛР 1 · Анализ данных">
<div class="cell-main"><span class="variant">В-5</span><span class="score muted">—</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td data-label="ЛР 2 · Сортировки">
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td data-label="ЛР 3 · Деревья поиска">
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td data-label="Пароль"><span class="badge warn">временный</span></td>
<td class="ta-r" data-label="Действия"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Кузнецова Мария</div><div class="student-no">2025021</div></td>
<td data-label="ЛР 1 · Анализ данных">
<div class="cell-main"><span class="variant">В-3</span><span class="score good">4.0</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st done">защита: готова</span></div>
</td>
<td data-label="ЛР 2 · Сортировки">
<div class="cell-main"><span class="variant">В-6</span><span class="score muted">—</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 03:07</span></div>
</td>
<td data-label="ЛР 3 · Деревья поиска">
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td data-label="Пароль"><span class="badge ok">изменён</span></td>
<td class="ta-r" data-label="Действия"><button class="btn btn-secondary btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<section class="components">
<p class="kicker">Элементы интерфейса</p>
<div class="row">
<button class="btn">Сохранить всё</button>
<button class="btn btn-secondary">Отмена</button>
<button class="btn btn-danger">Удалить</button>
</div>
<div class="row">
<input class="input" style="width:220px" value="Поле ввода">
<input class="input" style="width:130px" value="552314">
<button class="btn btn-accent-soft btn-sm">Выдать код</button>
</div>
<p class="kicker" style="margin-top:22px">Статусы: бейдж — только индикатор, действие — отдельной кнопкой</p>
<div class="row status-line">
<span class="name">ЛР 1 · Анализ данных</span>
<span class="badge ok">Опубликована</span>
<button class="btn btn-secondary btn-sm">Скрыть</button>
</div>
<div class="row status-line">
<span class="name">ЛР 2 · Сортировки</span>
<span class="badge warn">Черновик</span>
<button class="btn btn-accent-soft btn-sm">Опубликовать</button>
</div>
<div class="row status-line">
<span class="name">Глава 3 · Жадные алгоритмы</span>
<span class="badge">Скрыто</span>
<button class="btn btn-accent-soft btn-sm">Опубликовать</button>
</div>
<p class="kicker" style="margin-top:22px">Уведомления</p>
<div class="notice ok" style="margin-bottom:8px">Оценки опубликованы всей группе — студенты видят баллы и комментарии.</div>
<div class="notice warn" style="margin-bottom:8px">Срок сдачи ЛР 2 истекает завтра в 23:59 МСК.</div>
<div class="notice err" style="margin-bottom:8px">Не удалось сохранить файлы решения — повторите попытку.</div>
<div class="notice info">Новый студент добавлен: логин 2025031, пароль показан один раз.</div>
</section>
</main>
<footer class="page-footer">LMS · демо-шаблон · стиль Б2</footer>
<script>
function setAccent(name, btn) {
document.documentElement.setAttribute('data-accent', name);
document.querySelectorAll('.swatch').forEach(function (s) { s.classList.remove('active'); });
btn.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="ru" data-accent="bordeaux">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS — демо стиля Б3: минимализм, акцент бордо</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ─────────────────────────────────────────────────────────
Б3 = сдержанность Б1 + бордовый акцент.
Цвет только там, где он несёт смысл: акцент действий, точки
статусов защиты, два бейджа пароля. Всё остальное — нейтральное.
───────────────────────────────────────────────────────── */
:root {
--bg: #fafafa;
--surface: #ffffff;
--text: #131316;
--muted: #74747c;
--border: #e6e6e9;
--border-strong: #d4d4da;
--ok: #1a7f37;
--warn: #9a6700;
--radius: 8px;
}
:root[data-accent="bordeaux"] { --accent: #7a2333; --accent-hover: #5f1b28; --accent-soft: #f7edee; }
:root[data-accent="blue"] { --accent: #24509c; --accent-hover: #1c3f7e; --accent-soft: #edf2fa; }
:root[data-accent="graphite"] { --accent: #1b1c20; --accent-hover: #000000; --accent-soft: #eef0f3; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.55 "Inter", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.demo-note {
background: #131316; color: #fafafa; text-align: center;
font-size: 12px; padding: 6px 12px;
}
/* ── Переключатель акцента (только для демо) ───────────── */
.accent-bar {
background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 8px 12px; font-size: 12.5px; color: var(--muted); flex-wrap: wrap;
}
.swatch {
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent;
cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center;
}
.swatch span { width: 12px; height: 12px; border-radius: 50%; display: block; }
.swatch.active { border-color: var(--text); }
.sw-bordeaux span { background: #7a2333; }
.sw-blue span { background: #24509c; }
.sw-graphite span { background: #1b1c20; }
/* ── Шапка ─────────────────────────────────────────────── */
.page-header { background: var(--surface); border-bottom: 1px solid var(--border); }
.header-inner {
max-width: 1200px; margin: 0 auto; padding: 14px 28px;
display: flex; align-items: center; gap: 28px; flex-wrap: wrap;
}
.logo { font-size: 16px; font-weight: 700; color: var(--text); text-decoration: none; }
.logo b { color: var(--accent); }
.nav { display: flex; gap: 18px; flex: 1; flex-wrap: wrap; }
.nav a { color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500; }
.nav a.active { color: var(--accent); }
.nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 12px; font-size: 13.5px; }
/* ── Основная часть ────────────────────────────────────── */
.page-main { max-width: 1200px; margin: 0 auto; padding: 40px 28px 56px; }
.kicker { margin: 0 0 6px; font-size: 12px; font-weight: 500; color: var(--muted); }
h1 { margin: 0 0 8px; font-size: 24px; font-weight: 700; letter-spacing: -.02em; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
.meta { margin: 0; color: var(--muted); font-size: 13px; }
.meta b { color: var(--text); font-weight: 600; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
/* ── Таблица-ведомость ─────────────────────────────────── */
.matrix-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
table.matrix { width: 100%; border-collapse: collapse; }
.matrix th {
padding: 12px 16px; text-align: left; background: var(--surface);
font-size: 12px; font-weight: 500; color: var(--muted);
border-bottom: 1px solid var(--border); white-space: nowrap;
}
.matrix td { padding: 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix tbody tr:hover { background: #f7f7f8; }
.sticky { min-width: 200px; }
.student-name { font-weight: 600; font-size: 13.5px; }
.student-no { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.lab-no { display: block; color: var(--text); font-weight: 500; }
.lab-title { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); }
.cell-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.variant { font-weight: 600; font-size: 13.5px; }
.score { font-variant-numeric: tabular-nums; font-weight: 600; }
.score.muted { color: var(--muted); font-weight: 400; }
.cell-sub { margin-top: 6px; display: flex; gap: 14px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
.sub-link { color: var(--muted); text-decoration: none; }
.sub-link:hover { color: var(--text); text-decoration: underline; }
.st { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.st.done { color: var(--ok); } .st.done::before { background: var(--ok); }
.st.active { color: var(--warn); } .st.active::before { background: var(--warn); }
.st.code { color: var(--muted); }
.eye { cursor: default; color: var(--border-strong); font-size: 12px; }
.eye.on { color: var(--text); }
.ta-r { text-align: right; }
/* Действия — столбиком: ровные одинаковые кнопки друг под другом */
.actions-col { display: inline-flex; flex-direction: column; gap: 6px; align-items: stretch; min-width: 104px; }
.actions-col .btn { text-align: center; }
/* ── Кнопки, бейджи, поля ──────────────────────────────── */
.btn {
display: inline-block; padding: 8px 16px; font: inherit; font-size: 13px; font-weight: 500;
border: 1px solid transparent; background: var(--accent);
color: #fff; border-radius: var(--radius); cursor: pointer; text-decoration: none;
}
.btn:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text); }
.btn-secondary:hover { background: var(--bg); }
.btn-danger { background: var(--surface); border-color: #dcb9be; color: var(--accent); }
.btn-danger:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.input {
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
}
.input:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
/* Бейдж — «этикетка»: строгая рамка, разрядка капсом, приглушённый тон.
Некликабельный, всегда по-русски. */
.badge {
display: inline-block; padding: 2px 8px; font-size: 10.5px; font-weight: 600;
letter-spacing: .07em; text-transform: uppercase;
border-radius: 2px; border: 1px solid var(--border-strong);
background: transparent; color: var(--muted); cursor: default;
}
.badge.warn { color: #8a5a12; border-color: #d3bd8f; background: #faf5e9; }
.badge.ok { color: #2f6b3a; border-color: #b7cdbd; background: #f2f7f3; }
.notice { border: 1px solid var(--border); border-left: 3px solid var(--accent); background: var(--surface); border-radius: var(--radius); padding: 12px 16px; font-size: 13px; }
/* ── Секция элементов ──────────────────────────────────── */
.components { margin-top: 44px; border-top: 1px solid var(--border); padding-top: 24px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0; }
.status-line .name { font-weight: 600; min-width: 210px; }
.page-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 24px; }
/* ── Адаптивность таблицы: два режима ─────────────────────
>1080px — полная таблица;
≤1080px — горизонтальный скролл, колонка «Студент» прилипает слева.
На телефоне колонка сжимается, но остаётся фиксированной —
карточный режим убран по замечанию. */
@media (max-width: 1080px) {
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.matrix { min-width: 860px; }
th.sticky, td.sticky {
position: sticky; left: 0; z-index: 1;
background: var(--surface);
box-shadow: 8px 0 8px -8px rgba(19, 19, 22, .1);
}
tbody tr:hover td.sticky { background: #f7f7f8; }
}
@media (max-width: 720px) {
.page-main { padding: 24px 14px 40px; }
.header-inner { padding: 12px 14px; gap: 10px; }
.nav { gap: 10px; }
.toolbar .input { flex: 1 1 auto; }
th.sticky, td.sticky { min-width: 150px; }
.matrix td { padding: 12px; }
.matrix { min-width: 780px; }
.ta-r { white-space: nowrap; }
}
</style>
</head>
<body>
<div class="demo-note">Демо-шаблон «Б3» · сдержанность Б1 + бордовый акцент · цвет только там, где он несёт смысл · сузьте окно: таблица скроллится, колонка «Студент» прилипает</div>
<div class="accent-bar">
Акцентный цвет:
<button class="swatch sw-bordeaux active" title="Бордо" onclick="setAccent('bordeaux', this)"><span></span></button>
<button class="swatch sw-blue" title="Синий — для сравнения" onclick="setAccent('blue', this)"><span></span></button>
<button class="swatch sw-graphite" title="Графит — как в Б1" onclick="setAccent('graphite', this)"><span></span></button>
</div>
<header class="page-header">
<div class="header-inner">
<a class="logo" href="#">LMS<b>.</b></a>
<nav class="nav">
<a href="#" class="active">Группы</a>
<a href="#">Пользователи</a>
<a href="#">Лабораторные</a>
</nav>
<div class="user">
<span>Копнин В.</span>
<button class="btn btn-secondary btn-sm">Выйти</button>
</div>
</div>
</header>
<main class="page-main">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1>М202-1 · Первый курс</h1>
<p class="meta">24 студента · <b>3 опубликованные</b> лабораторные · длительность защиты 30 мин</p>
</div>
<div class="actions">
<button class="btn btn-secondary">Скачать пароли</button>
<button class="btn">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input" style="width:260px" placeholder="Поиск по студенту…">
<select class="input"><option>все лабораторные</option><option>ЛР 1</option><option>ЛР 2</option></select>
</div>
<div class="matrix-wrap">
<div class="table-scroll">
<table class="matrix">
<thead>
<tr>
<th class="sticky">Студент</th>
<th><span class="lab-no">ЛР 1</span><span class="lab-title">Анализ данных</span></th>
<th><span class="lab-no">ЛР 2</span><span class="lab-title">Сортировки</span></th>
<th><span class="lab-no">ЛР 3</span><span class="lab-title">Деревья поиска</span></th>
<th>Пароль</th>
<th class="ta-r">Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky"><div class="student-name">Иванова Анна</div><div class="student-no">2025001</div></td>
<td>
<div class="cell-main"><span class="variant">В-7</span><span class="score">4.5</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 2</a><span class="st done">защита: готова</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-2</span><span class="score">3.0</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st code">защита: код 552314</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><span class="actions-col"><button class="btn btn-secondary btn-sm">Сбросить</button><button class="btn btn-sm btn-danger">Отчислить</button></span></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Петров Павел</div><div class="student-no">2025002</div></td>
<td>
<div class="cell-main"><span class="variant">В-1</span><span class="score">5.0</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 3</a><span class="st done">защита: готова</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-4</span><span class="score muted">—</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 12:34</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score muted">—</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><span class="actions-col"><button class="btn btn-secondary btn-sm">Сбросить</button><button class="btn btn-sm btn-danger">Отчислить</button></span></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Сидоров Алексей</div><div class="student-no">2025017</div></td>
<td>
<div class="cell-main"><span class="variant">В-5</span><span class="score muted">—</span><span class="eye on" title="Оценка опубликована">◉</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><span class="actions-col"><button class="btn btn-secondary btn-sm">Сбросить</button><button class="btn btn-sm btn-danger">Отчислить</button></span></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Кузнецова Мария</div><div class="student-no">2025021</div></td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score">4.0</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st done">защита: готова</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-6</span><span class="score muted">—</span><span class="eye" title="Оценка скрыта">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 03:07</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">вариант не выдан</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><span class="actions-col"><button class="btn btn-secondary btn-sm">Сбросить</button><button class="btn btn-sm btn-danger">Отчислить</button></span></td>
</tr>
</tbody>
</table>
</div>
</div>
<section class="components">
<p class="kicker">Элементы интерфейса</p>
<div class="row">
<button class="btn">Сохранить всё</button>
<button class="btn btn-secondary">Отмена</button>
<button class="btn btn-danger">Удалить</button>
</div>
<div class="row">
<input class="input" style="width:220px" value="Поле ввода">
<input class="input" style="width:130px" value="552314">
</div>
<p class="kicker" style="margin-top:22px">Статусы: бейдж — только индикатор, действие — отдельной кнопкой</p>
<div class="row status-line">
<span class="name">ЛР 1 · Анализ данных</span>
<span class="badge ok">Опубликована</span>
<button class="btn btn-secondary btn-sm">Скрыть</button>
</div>
<div class="row status-line">
<span class="name">ЛР 2 · Сортировки</span>
<span class="badge warn">Черновик</span>
<button class="btn btn-sm">Опубликовать</button>
</div>
<div class="row status-line">
<span class="name">Глава 3 · Жадные алгоритмы</span>
<span class="badge">Скрыто</span>
<button class="btn btn-sm">Опубликовать</button>
</div>
<p class="kicker" style="margin-top:22px">Уведомление</p>
<div class="notice">Оценки опубликованы всей группе — студенты видят баллы и комментарии.</div>
</section>
</main>
<footer class="page-footer">LMS · демо-шаблон · стиль Б3</footer>
<script>
function setAccent(name, btn) {
document.documentElement.setAttribute('data-accent', name);
document.querySelectorAll('.swatch').forEach(function (s) { s.classList.remove('active'); });
btn.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LMS — демо стиля В: институциональный</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #edf1f6;
--surface: #ffffff;
--text: #16202b;
--muted: #5c6b7c;
--border: #c9d4e2;
--border-strong: #9fb0c4;
--accent: #1f4e8c;
--accent-hover: #173c6d;
--accent-soft: #e3eaf3;
--danger: #a02929;
--ok: #2e6b34;
--warn: #8a5a12;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.5 "PT Sans", Roboto, "Segoe UI", system-ui, sans-serif;
}
.demo-note {
background: #16202b; color: #e8eef5; text-align: center;
font-size: 12px; padding: 6px 12px;
}
/* ── Шапка-плашка ──────────────────────────────────────── */
.page-header { background: var(--accent); color: #ffffff; }
.header-inner {
max-width: 1200px; margin: 0 auto; padding: 10px 24px;
display: flex; align-items: center; gap: 24px;
}
.logo { font-size: 17px; font-weight: 700; color: #ffffff; text-decoration: none; letter-spacing: .04em; }
.nav { display: flex; gap: 4px; flex: 1; }
.nav a {
color: #d7e2ef; text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 6px 12px; border-radius: 4px;
}
.nav a.active { background: rgba(255, 255, 255, .16); color: #ffffff; }
.nav a:hover { background: rgba(255, 255, 255, .1); color: #ffffff; }
.user { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: #e8eef5; }
.user .btn { background: rgba(255, 255, 255, .12); border-color: transparent; color: #ffffff; }
.user .btn:hover { background: rgba(255, 255, 255, .2); }
/* ── Основная часть ────────────────────────────────────── */
.page-main { max-width: 1200px; margin: 0 auto; padding: 24px 24px 48px; }
.kicker { margin: 0 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
h1 {
margin: 0 0 6px; padding-left: 12px; font-size: 22px; font-weight: 700;
border-left: 4px solid var(--accent); line-height: 1.25;
}
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.meta { margin: 0 0 0 16px; color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 8px; }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
/* ── Таблица-ведомость: полная сетка ───────────────────── */
.matrix-wrap { background: var(--surface); border: 1px solid var(--border-strong); }
table.matrix { width: 100%; border-collapse: collapse; }
.matrix th {
padding: 9px 12px; text-align: left;
background: var(--accent-soft); color: var(--text);
font-size: 12px; font-weight: 700;
border: 1px solid var(--border); border-top: none;
}
.matrix th:first-child { border-left: none; }
.matrix th:last-child { border-right: none; }
.matrix td { padding: 10px 12px; border: 1px solid var(--border); vertical-align: top; }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix td:first-child { border-left: none; }
.matrix td:last-child { border-right: none; }
.matrix tbody tr:nth-child(even) { background: #f7f9fc; }
.sticky { min-width: 190px; }
.student-name { font-weight: 700; }
.student-no { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.lab-no { display: block; font-weight: 700; }
.lab-title { display: block; font-weight: 400; font-size: 11px; color: var(--muted); }
.cell-main { display: flex; align-items: baseline; gap: 10px; }
.variant { font-weight: 700; color: var(--accent); }
.score { font-variant-numeric: tabular-nums; font-weight: 700; }
.score.muted { color: var(--muted); font-weight: 400; }
.cell-sub { margin-top: 3px; display: flex; gap: 10px; font-size: 12px; color: var(--muted); }
.sub-link { color: var(--accent); text-decoration: underline; }
.st { white-space: nowrap; }
.st.done { color: var(--ok); font-weight: 700; }
.st.active { color: var(--warn); font-weight: 700; }
.eye { cursor: default; color: var(--border-strong); font-size: 13px; }
.eye.on { color: var(--accent); }
.ta-r { text-align: right; }
/* ── Кнопки, бейджи, поля ──────────────────────────────── */
.btn {
display: inline-block; padding: 6px 14px; font: inherit; font-size: 13px; font-weight: 700;
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text); border-radius: 4px; cursor: pointer; text-decoration: none;
}
.btn:hover { background: #f2f6fb; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--surface); border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: #f7e9e9; }
.btn-sm { padding: 4px 9px; font-size: 12px; }
.input {
padding: 6px 10px; font: inherit; font-size: 13px; color: var(--text);
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 4px;
}
.input:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
.badge {
display: inline-block; padding: 2px 8px; font-size: 11px; font-weight: 700;
border-radius: 3px; background: var(--accent-soft); color: var(--accent);
}
.badge.warn { background: #f7ecd4; color: var(--warn); }
.badge.ok { background: #e2efe3; color: var(--ok); }
.notice { border: 1px solid var(--border); border-left: 4px solid var(--accent); background: var(--surface); padding: 10px 14px; font-size: 13px; }
/* ── Секция элементов ──────────────────────────────────── */
.components { margin-top: 28px; border: 1px solid var(--border); background: var(--surface); padding: 16px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.page-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 20px; }
</style>
</head>
<body>
<div class="demo-note">Демо-шаблон «В. Институциональный» · данные условные · тёмная тема будет в реализации</div>
<header class="page-header">
<div class="header-inner">
<a class="logo" href="#">LMS</a>
<nav class="nav">
<a href="#" class="active">Группы</a>
<a href="#">Пользователи</a>
<a href="#">Лабораторные</a>
</nav>
<div class="user">
<span>Копнин В.</span>
<button class="btn btn-sm">Выйти</button>
</div>
</div>
</header>
<main class="page-main">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1>М202-1 · Первый курс</h1>
<p class="meta">24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин</p>
</div>
<div class="actions">
<button class="btn">Скачать пароли</button>
<button class="btn btn-primary">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input" style="width:260px" placeholder="Поиск по студенту…">
<select class="input"><option>все лабораторные</option><option>ЛР 1</option><option>ЛР 2</option></select>
</div>
<div class="matrix-wrap">
<table class="matrix">
<thead>
<tr>
<th class="sticky">Студент</th>
<th><span class="lab-no">ЛР 1</span><span class="lab-title">Анализ данных</span></th>
<th><span class="lab-no">ЛР 2</span><span class="lab-title">Сортировки</span></th>
<th><span class="lab-no">ЛР 3</span><span class="lab-title">Деревья поиска</span></th>
<th>Пароль</th>
<th class="ta-r">Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky"><div class="student-name">Иванова Анна</div><div class="student-no">2025001</div></td>
<td>
<div class="cell-main"><span class="variant">В-7</span><span class="score">4.5</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 2</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-2</span><span class="score">3.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st code">защита: код 552314</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Петров Павел</div><div class="student-no">2025002</div></td>
<td>
<div class="cell-main"><span class="variant">В-1</span><span class="score">5.0</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 3</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-4</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 12:34</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Сидоров Алексей</div><div class="student-no">2025017</div></td>
<td>
<div class="cell-main"><span class="variant">В-5</span><span class="score muted">—</span><span class="eye on">◉</span></div>
<div class="cell-sub"><span class="st">не сдано</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge warn">временный</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
<tr>
<td class="sticky"><div class="student-name">Кузнецова Мария</div><div class="student-no">2025021</div></td>
<td>
<div class="cell-main"><span class="variant">В-3</span><span class="score">4.0</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st done">защита: готов</span></div>
</td>
<td>
<div class="cell-main"><span class="variant">В-6</span><span class="score muted">—</span><span class="eye">○</span></div>
<div class="cell-sub"><a class="sub-link" href="#">сдача ↓ 1</a><span class="st active">защита: идёт · 03:07</span></div>
</td>
<td>
<div class="cell-main"><span class="score muted">—</span></div>
<div class="cell-sub"><span class="st">не выдан</span></div>
</td>
<td><span class="badge ok">изменён</span></td>
<td class="ta-r"><button class="btn btn-sm">Сбросить</button> <button class="btn btn-sm btn-danger">Отчислить</button></td>
</tr>
</tbody>
</table>
</div>
<section class="components">
<p class="kicker">Элементы интерфейса</p>
<div class="row">
<button class="btn btn-primary">Сохранить всё</button>
<button class="btn">Отмена</button>
<button class="btn btn-danger">Удалить</button>
<span class="badge ok">published</span>
<span class="badge warn">draft</span>
</div>
<div class="row">
<input class="input" style="width:220px" value="Поле ввода">
<input class="input" style="width:120px" value="552314">
</div>
<div class="notice">Оценки опубликованы всей группе — студенты видят баллы и комментарии.</div>
</section>
</main>
<footer class="page-footer">LMS · демо-шаблон · стиль В</footer>
</body>
</html>

View File

@@ -7,6 +7,8 @@ dependencies {
api(libs.springWeb) api(libs.springWeb)
api(libs.springBootAutoconfigure) api(libs.springBootAutoconfigure)
// spring-tx нужен для обработчика DataIntegrityViolationException в LmsExceptionHandler.
api("org.springframework:spring-tx")
// Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию. // Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию.
api(libs.jacksonModuleKotlin) api(libs.jacksonModuleKotlin)
} }

View File

@@ -37,3 +37,8 @@ class UnauthorizedException(
message: String, message: String,
code: String? = null, code: String? = null,
) : LmsException(HttpStatus.UNAUTHORIZED, message, code) ) : LmsException(HttpStatus.UNAUTHORIZED, message, code)
class TooManyRequestsException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.TOO_MANY_REQUESTS, message, code)

View File

@@ -1,9 +1,11 @@
package ru.ysu.lms.common package ru.ysu.lms.common
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
@RestControllerAdvice @RestControllerAdvice
class LmsExceptionHandler { class LmsExceptionHandler {
@@ -34,4 +36,37 @@ class LmsExceptionHandler {
), ),
) )
} }
/** Некорректный UUID/аргумент в пути или теле — клиентская ошибка, а не 500. */
@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgument(): ResponseEntity<ApiError> =
ResponseEntity.badRequest().body(
ApiError(
title = "Bad Request",
status = 400,
detail = "Некорректный параметр запроса",
),
)
/** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrity(): ResponseEntity<ApiError> =
ResponseEntity.status(409).body(
ApiError(
title = "Conflict",
status = 409,
detail = "Действие конфликтует с уже существующими данными",
),
)
/** Превышение лимита загрузки — вместо 500 понятный 413. */
@ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMaxUpload(): ResponseEntity<ApiError> =
ResponseEntity.status(413).body(
ApiError(
title = "Payload Too Large",
status = 413,
detail = "Файл слишком большой",
),
)
} }

View File

@@ -10,6 +10,7 @@ import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID import java.util.UUID
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@@ -233,16 +234,45 @@ class ContentImporter(
private fun readZip(bytes: ByteArray): Map<String, ByteArray> { private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>() val result = mutableMapOf<String, ByteArray>()
var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry while (true) {
while (entry != null) { val entry = zis.nextEntry ?: break
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes() if (entry.isDirectory) continue
entry = zis.nextEntry val data = readBounded(zis, MAX_ENTRY_BYTES, "запись «${entry.name}»")
totalUncompressed += data.size
if (totalUncompressed > MAX_TOTAL_BYTES) {
throw BadRequestException(
"Архив слишком большой: после распаковки суммарно более ${MAX_TOTAL_BYTES / MB} МБ",
)
}
result[entry.name.removePrefix("/")] = data
} }
} }
return result return result
} }
/** Читает запись архива с ограничением размера — защита от zip-бомб. */
private fun readBounded(
zis: ZipInputStream,
maxEntryBytes: Long,
what: String,
): ByteArray {
val buffer = ByteArrayOutputStream()
val chunk = ByteArray(64 * 1024)
var total = 0L
while (true) {
val n = zis.read(chunk)
if (n < 0) break
total += n
if (total > maxEntryBytes) {
throw BadRequestException("$what распаковывается более чем в ${maxEntryBytes / MB} МБ")
}
buffer.write(chunk, 0, n)
}
return buffer.toByteArray()
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> { private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines() val lines = content.lines()
@@ -285,4 +315,10 @@ class ContentImporter(
val seenSections: MutableSet<UUID> = mutableSetOf() val seenSections: MutableSet<UUID> = mutableSetOf()
val seenArticles: MutableSet<UUID> = mutableSetOf() val seenArticles: MutableSet<UUID> = mutableSetOf()
} }
private companion object {
const val MB = 1024L * 1024L
const val MAX_ENTRY_BYTES = 128L * MB
const val MAX_TOTAL_BYTES = 256L * MB
}
} }

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.academic.controller package ru.ysu.lms.academic.controller
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
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
@@ -11,8 +12,10 @@ import ru.ysu.lms.academic.content.ReorderSections
import ru.ysu.lms.academic.content.ReorderTree import ru.ysu.lms.academic.content.ReorderTree
import java.util.UUID import java.util.UUID
/** Реордер материалов: меняет публичный порядок глав/разделов/статей — только админ. */
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentOrderController( class ContentOrderController(
private val order: ContentOrderService, private val order: ContentOrderService,
) { ) {

View File

@@ -60,12 +60,19 @@ class ContentPublicController(
assetStorage.read(name) assetStorage.read(name)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM } val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity val builder =
.ok() ResponseEntity
.contentType(media) .ok()
.cacheControl( .contentType(media)
org.springframework.http.CacheControl .cacheControl(
.maxAge(java.time.Duration.ofDays(7)), org.springframework.http.CacheControl
).body(FileSystemResource(path)) .maxAge(java.time.Duration.ofDays(7)),
)
// SVG может содержать скрипты: при открытии напрямую как документа запрещаем их исполнение.
if (media.isCompatibleWith(MediaType.parseMediaType("image/svg+xml"))) {
builder.header("Content-Security-Policy", "script-src 'none'; object-src 'none'")
}
builder.header("X-Content-Type-Options", "nosniff")
return builder.body(FileSystemResource(path))
} }
} }

View File

@@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
/** /**
@@ -55,16 +56,45 @@ class LabImportParser {
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> { private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>() val entries = mutableMapOf<String, ByteArray>()
var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry while (true) {
while (entry != null) { val entry = zis.nextEntry ?: break
if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes() if (entry.isDirectory) continue
entry = zis.nextEntry val data = readBounded(zis, MAX_ENTRY_BYTES, "запись «${entry.name}»")
totalUncompressed += data.size
if (totalUncompressed > MAX_TOTAL_BYTES) {
throw BadRequestException(
"Архив слишком большой: после распаковки суммарно более ${MAX_TOTAL_BYTES / MB} МБ",
)
}
entries[entry.name] = data
} }
} }
return entries return entries
} }
/** Читает запись архива с ограничением размера — защита от zip-бомб. */
private fun readBounded(
zis: ZipInputStream,
maxEntryBytes: Long,
what: String,
): ByteArray {
val buffer = ByteArrayOutputStream()
val chunk = ByteArray(64 * 1024)
var total = 0L
while (true) {
val n = zis.read(chunk)
if (n < 0) break
total += n
if (total > maxEntryBytes) {
throw BadRequestException("$what распаковывается более чем в ${maxEntryBytes / MB} МБ")
}
buffer.write(chunk, 0, n)
}
return buffer.toByteArray()
}
private fun readMeta(entries: Map<String, ByteArray>): LabMeta { private fun readMeta(entries: Map<String, ByteArray>): LabMeta {
val metaEntry = val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") } entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
@@ -146,4 +176,10 @@ class LabImportParser {
"advanced", "повышенная", "повышенной", "hard" -> "advanced" "advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim() else -> s.lowercase().trim()
} }
private companion object {
const val MB = 1024L * 1024L
const val MAX_ENTRY_BYTES = 128L * MB
const val MAX_TOTAL_BYTES = 256L * MB
}
} }

View File

@@ -26,15 +26,18 @@ class JwtAuthenticationFilter(
): Mono<Void> { ): Mono<Void> {
val path = exchange.request.path.value() val path = exchange.request.path.value()
if (isPublic(path)) { if (isPublic(path)) {
return chain.filter(exchange) // Клиентские X-Account-* не должны доходить до сервисов: эти заголовки ставит только gateway.
val stripped = stripAccountHeaders(exchange)
return chain.filter(stripped)
} }
val token = val token =
bearerToken(exchange) bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход") ?: return unauthorized(exchange, "Требуется вход")
return try { return try {
val jwt = jwtDecoder.decode(token) val jwt = jwtDecoder.decode(token)
val stripped = stripAccountHeaders(exchange)
val request = val request =
exchange.request stripped.request
.mutate() .mutate()
.header("X-Account-Id", jwt.subject.orEmpty()) .header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty()) .header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
@@ -48,6 +51,17 @@ class JwtAuthenticationFilter(
override fun getOrder(): Int = -100 override fun getOrder(): Int = -100
private fun stripAccountHeaders(exchange: ServerWebExchange): ServerWebExchange =
exchange
.mutate()
.request { req ->
req.headers { headers ->
headers.remove("X-Account-Id")
headers.remove("X-Account-Login")
headers.remove("X-Account-Role")
}
}.build()
private fun isPublic(path: String): Boolean = private fun isPublic(path: String): Boolean =
path.startsWith("/actuator") || path.startsWith("/actuator") ||
path == "/api/v1/auth/login" || path == "/api/v1/auth/login" ||

View File

@@ -20,7 +20,7 @@ spring:
- id: progress - id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083} uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates: predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/submissions/** - Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/purge,/api/v1/purge/**,/api/v1/submissions/**
management: management:
endpoints: endpoints:

View File

@@ -27,6 +27,7 @@ class AuthService(
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val jwtEncoder: JwtEncoder, private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
private val backoff: LoginBackoff,
) { ) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN) fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@@ -51,14 +52,16 @@ class AuthService(
return toResponse(account, account.login) return toResponse(account, account.login)
} }
@Transactional(readOnly = true) /** Без @Transactional на методе: задержка backoff не должна держать JDBC-соединение. */
fun login(request: LoginRequest): AuthResponse { fun login(request: LoginRequest): AuthResponse {
val account = val login = request.login.trim()
accounts.findByLogin(request.login.trim()) backoff.delayBeforeAttempt(login)
?: throw UnauthorizedException("Неверный логин или пароль") val account = accounts.findByLogin(login)
if (!passwordEncoder.matches(request.password, account.passwordHash)) { if (account == null || !passwordEncoder.matches(request.password, account.passwordHash)) {
backoff.recordFailure(login)
throw UnauthorizedException("Неверный логин или пароль") throw UnauthorizedException("Неверный логин или пароль")
} }
backoff.reset(login)
return toResponse(account, displayName(account)) return toResponse(account, displayName(account))
} }

View File

@@ -0,0 +1,74 @@
package ru.ysu.lms.identity.auth
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.TooManyRequestsException
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import java.time.Duration
import java.time.Instant
import kotlin.math.min
import kotlin.math.pow
/**
* Экспоненциальная задержка между неудачными попытками входа (1с, 2с, 4с…).
* Задержка растёт по числу неудач, а не по времени ожидания ответа, поэтому
* перебор пароля становится бессмысленно долгим, а легитимный пользователь,
* знающий пароль, блокировок не видит. Счётчик живёт в БД (переживает рестарт)
* и обнуляется по успеху либо по истечении окна [WINDOW].
*/
@Service
class LoginBackoff(
private val attempts: LoginAttemptRepository,
) {
/** Короткие задержки выдерживаем в потоке запроса; длиннее — сразу 429 с паузой для клиента. */
fun delayBeforeAttempt(login: String) {
val failures = effectiveFailures(login)
if (failures <= 0) return
val delaySec = delaySecondsFor(failures)
if (delaySec > INLINE_SLEEP_SECONDS) {
throw TooManyRequestsException(
"Слишком много неудачных попыток входа. Повторите через $delaySec с.",
)
}
Thread.sleep(delaySec * 1000)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun recordFailure(login: String) {
val now = Instant.now()
val row = attempts.findById(login).orElse(null)
if (row == null) {
attempts.save(LoginAttempt(login = login, failures = 1, lastFailureAt = now))
} else {
row.failures += 1
row.lastFailureAt = now
attempts.save(row)
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun reset(login: String) {
attempts.findById(login).orElse(null)?.let(attempts::delete)
}
/** Неудачи внутри окна [WINDOW]; устаревшую строку сразу гасим. */
private fun effectiveFailures(login: String): Int {
val row = attempts.findById(login).orElse(null) ?: return 0
if (row.lastFailureAt.isBefore(Instant.now().minus(WINDOW))) {
attempts.delete(row)
return 0
}
return row.failures
}
companion object {
private val WINDOW: Duration = Duration.ofMinutes(15)
private const val MAX_DELAY_SECONDS = 60L
private const val INLINE_SLEEP_SECONDS = 5L
/** 1, 2, 4, 8 … секунд, с потолком [MAX_DELAY_SECONDS]. */
fun delaySecondsFor(failures: Int): Long = min(2.0.pow((failures - 1).coerceAtMost(6)), MAX_DELAY_SECONDS.toDouble()).toLong()
}
}

View File

@@ -0,0 +1,20 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
/** Счётчик неудачных попыток входа для конкретного логина (см. LoginBackoff). */
@Entity
@Table(name = "login_attempts")
class LoginAttempt(
@Id
@Column(nullable = false, length = 64)
var login: String = "",
@Column(nullable = false)
var failures: Int = 0,
@Column(name = "last_failure_at", nullable = false)
var lastFailureAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.LoginAttempt
interface LoginAttemptRepository : JpaRepository<LoginAttempt, String>

View File

@@ -262,6 +262,63 @@ class GroupService(
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows) return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
} }
/** Список всех пользователей (студентов) с фильтром по подстроке. */
@Transactional(readOnly = true)
fun users(query: String?): List<AdminUser> {
val q = query?.trim()?.lowercase().orEmpty()
val enrollByStudent =
enrollments
.findAll()
.mapNotNull { e ->
val sid = e.student?.accountId
val group = e.group
if (sid != null && group != null) sid to (group.id to group.name) else null
}.toMap()
return students
.findAll()
.mapNotNull { s ->
val account = s.account ?: return@mapNotNull null
val groupInfo = s.accountId?.let { enrollByStudent[it] }
AdminUser(
id = account.id.toString(),
login = account.login,
role = account.role.name,
studentNo = s.studentNo,
fullName = s.fullName,
basis = s.basis,
groupId = groupInfo?.first?.toString(),
groupName = groupInfo?.second,
)
}.filter { user ->
q.isEmpty() ||
user.fullName.lowercase().contains(q) ||
user.login.lowercase().contains(q) ||
user.studentNo.lowercase().contains(q) ||
(user.groupName?.lowercase()?.contains(q) == true)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
}
/** Удаляет студента из identity: зачисление, профиль, аккаунт. */
@Transactional
fun deleteUser(
accountId: UUID,
currentAccountId: UUID,
): DeleteUserResult {
if (accountId == currentAccountId) {
throw BadRequestException("Нельзя удалить собственный аккаунт")
}
val account = accounts.findById(accountId).orElseThrow { NotFoundException("Пользователь не найден") }
if (account.role != LmsRole.STUDENT) {
throw BadRequestException("Удалять можно только студенческие аккаунты")
}
val student = students.findById(accountId).orElse(null)
val fullName = student?.fullName.orEmpty()
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
if (student != null) students.delete(student)
accounts.delete(account)
return DeleteUserResult(accountId.toString(), account.login, fullName)
}
@Transactional @Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse { fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student = val student =

View File

@@ -67,6 +67,24 @@ data class StudentCandidate(
val currentGroupName: String?, val currentGroupName: String?,
) )
/** Пользователь для страницы управления: аккаунт + профиль студента. */
data class AdminUser(
val id: String,
val login: String,
val role: String,
val studentNo: String,
val fullName: String,
val basis: String,
val groupId: String?,
val groupName: String?,
)
data class DeleteUserResult(
val id: String,
val login: String,
val fullName: String,
)
class ExportFile( class ExportFile(
val bytes: ByteArray, val bytes: ByteArray,
val filename: String, val filename: String,

View File

@@ -1,9 +1,14 @@
package ru.ysu.lms.identity.roster package ru.ysu.lms.identity.roster
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.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.util.UUID import java.util.UUID
@@ -17,4 +22,17 @@ class StudentController(
fun resetPassword( fun resetPassword(
@PathVariable id: UUID, @PathVariable id: UUID,
): ResetPasswordResponse = groupService.resetPassword(id) ): ResetPasswordResponse = groupService.resetPassword(id)
/** Поиск по всем пользователям (для страницы управления). */
@GetMapping
fun list(
@RequestParam(required = false, name = "q") query: String?,
): List<AdminUser> = groupService.users(query)
/** Полное удаление пользователя из identity. */
@DeleteMapping("/{id}")
fun delete(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject))
} }

View File

@@ -0,0 +1,7 @@
-- Счётчик неудачных входов для экспоненциальной задержки (защита от перебора паролей).
CREATE TABLE login_attempts (
login varchar(64) PRIMARY KEY,
failures integer NOT NULL DEFAULT 0,
last_failure_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,142 @@
package ru.ysu.lms.identity
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
import ru.ysu.lms.common.TooManyRequestsException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginBackoff
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Duration
import java.time.Instant
import java.util.Optional
class AuthServiceLoginBackoffTest {
private val login = "2025001"
private val account = Account(login = login, passwordHash = "hash", role = LmsRole.STUDENT)
/** Не relaxed: дженерик save у JpaRepository в relaxed-режиме ломает тип возвращаемого значения. */
private fun attemptsRepo(): LoginAttemptRepository {
val repo = mockk<LoginAttemptRepository>()
every { repo.save(any<LoginAttempt>()) } answers { firstArg() }
every { repo.delete(any<LoginAttempt>()) } just Runs
return repo
}
private fun service(
accounts: AccountRepository,
attempts: LoginAttemptRepository,
matches: Boolean,
): AuthService {
val students = mockk<StudentRepository>()
every { students.findById(account.id) } returns Optional.empty()
val passwordEncoder = mockk<PasswordEncoder>()
every { passwordEncoder.matches(any(), any()) } returns matches
val jwtEncoder = mockk<JwtEncoder>()
every { jwtEncoder.encode(any<JwtEncoderParameters>()) } returns
mockk<Jwt> { every { tokenValue } returns "token" }
val backoff = LoginBackoff(attempts)
return AuthService(
accounts,
students,
passwordEncoder,
jwtEncoder,
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
backoff,
)
}
@Test
fun `failed login records failure`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returns Optional.empty()
val svc = service(accounts, attempts, matches = false)
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
.isInstanceOf(UnauthorizedException::class.java)
verify {
attempts.save(
match {
it.login == login &&
it.failures == 1 &&
it.lastFailureAt.isAfter(Instant.now().minusSeconds(5))
},
)
}
}
@Test
fun `many failures rejected with 429 before password check`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
every { attempts.findById(login) } returns
Optional.of(LoginAttempt(login = login, failures = 6, lastFailureAt = Instant.now()))
val svc = service(accounts, attempts, matches = true)
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "secret")) }
.isInstanceOf(TooManyRequestsException::class.java)
.hasMessageContaining("32")
verify(exactly = 0) { accounts.findByLogin(any()) }
}
@Test
fun `successful login resets counter`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
val stale = LoginAttempt(login = login, failures = 2, lastFailureAt = Instant.now())
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returnsMany
listOf(Optional.empty(), Optional.of(stale))
val svc = service(accounts, attempts, matches = true)
val response = svc.login(LoginRequest(login = login, password = "secret"))
assertThat(response.token).isEqualTo("token")
verify { attempts.delete(stale) }
}
@Test
fun `backoff grows exponentially and caps at 60s`() {
assertThat(LoginBackoff.delaySecondsFor(1)).isEqualTo(1)
assertThat(LoginBackoff.delaySecondsFor(2)).isEqualTo(2)
assertThat(LoginBackoff.delaySecondsFor(3)).isEqualTo(4)
assertThat(LoginBackoff.delaySecondsFor(4)).isEqualTo(8)
assertThat(LoginBackoff.delaySecondsFor(7)).isEqualTo(60)
assertThat(LoginBackoff.delaySecondsFor(50)).isEqualTo(60)
}
@Test
fun `stale failures outside window are ignored and cleared`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
val stale = LoginAttempt(login = login, failures = 9, lastFailureAt = Instant.now().minusSeconds(16 * 60))
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returns Optional.of(stale)
val svc = service(accounts, attempts, matches = false)
// failures=9 должны были дать 429; из-за устаревания строки — обычная неудача входа.
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
.isInstanceOf(UnauthorizedException::class.java)
verify { attempts.delete(stale) }
}
}

View File

@@ -2,15 +2,18 @@ package ru.ysu.lms.progress
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.net.http.HttpClient
import java.time.Duration
@Service @Service
class AcademicClient( class AcademicClient(
@Value("\${lms.academic.base-url}") baseUrl: String, @Value("\${lms.academic.base-url}") baseUrl: String,
) { ) {
private val client = RestClient.builder().baseUrl(baseUrl).build() private val client = restClient(baseUrl)
fun getLab( fun getLab(
labId: String, labId: String,
@@ -41,7 +44,7 @@ class AcademicClient(
class IdentityClient( class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String, @Value("\${lms.identity.base-url}") baseUrl: String,
) { ) {
private val client = RestClient.builder().baseUrl(baseUrl).build() private val client = restClient(baseUrl)
fun getGroup( fun getGroup(
groupId: String, groupId: String,
@@ -65,3 +68,20 @@ class IdentityClient(
.body(type) ?: emptyList() .body(type) ?: emptyList()
} }
} }
/** Таймауты обязательны: зависший downstream держит JDBC-соединение из пула и вешает сервис. */
private const val CONNECT_TIMEOUT_SECONDS = 3L
private const val READ_TIMEOUT_SECONDS = 30L
private fun restClient(baseUrl: String): RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
).build()

View File

@@ -68,6 +68,28 @@ class DefenseAdminController(
) )
} }
/** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */
@GetMapping("/watch")
fun watch(
@RequestParam labs: String,
): List<DefenseAdminDto> {
val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() }
return service.listForLabs(ids).map {
DefenseAdminDto(
it.id,
it.studentId,
it.labId,
it.status,
it.code,
it.assignmentMd,
it.startedAt,
it.readyAt,
it.readyReason,
it.secondsLeft,
)
}
}
@PostMapping("/{id}/issue-code") @PostMapping("/{id}/issue-code")
fun issueCode( fun issueCode(
@PathVariable id: UUID, @PathVariable id: UUID,

View File

@@ -30,6 +30,17 @@ class DefenseMyController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideForStudent(it) } ): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideForStudent(it) }
/** Компактный статус для синхронизации таймера раз в ~10 с. */
@GetMapping("/{labId}/status")
fun status(
@PathVariable labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): Map<String, Any?> {
val d = service.mySession(UUID.fromString(jwt.subject), labId)
if (d == null) return mapOf("status" to "none", "secondsLeft" to null, "readyReason" to null)
return mapOf("status" to d.status, "secondsLeft" to d.secondsLeft, "readyReason" to d.readyReason)
}
/** Студент не должен видеть ни код, ни текст задания до ввода кода. */ /** Студент не должен видеть ни код, ни текст задания до ввода кода. */
private fun hideForStudent(d: DefenseDto): DefenseDto = private fun hideForStudent(d: DefenseDto): DefenseDto =
if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d

View File

@@ -102,12 +102,21 @@ class DefenseService(
sessions.save(s) sessions.save(s)
} }
@Transactional(readOnly = true) /** Не readOnly: внутри переносим истёкшие защиты в ready — изменения должны сохраниться. */
@Transactional
fun listForLab(labId: UUID): List<DefenseDto> { fun listForLab(labId: UUID): List<DefenseDto> {
syncTimeouts() syncTimeouts()
return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) } return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) }
} }
/** Наблюдение за сессиями сразу по нескольким лабораторным (раз в ~10 с со страницы группы). */
@Transactional
fun listForLabs(labIds: Collection<UUID>): List<DefenseDto> {
if (labIds.isEmpty()) return emptyList()
syncTimeouts()
return sessions.findByLabIdIn(labIds).map { toDto(it) }
}
@Transactional @Transactional
fun startByCode( fun startByCode(
studentId: UUID, studentId: UUID,

View File

@@ -180,3 +180,18 @@ data class PublicationSetRequest(
val labId: String, val labId: String,
val studentIds: List<String> = emptyList(), val studentIds: List<String> = emptyList(),
) )
/** Агрегированные данные одной лабораторной по всем студентам группы (для страницы группы одним запросом). */
data class LabDataResponse(
val labId: String,
val assignments: List<VariantAssignmentResponse> = emptyList(),
val levels: List<LevelAssignmentResponse> = emptyList(),
val submissions: List<SubmissionDto> = emptyList(),
val defenses: List<DefenseDto> = emptyList(),
val publication: PublicationState = PublicationState(),
)
data class GroupLabDataResponse(
val groupId: String,
val labs: List<LabDataResponse> = emptyList(),
)

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress
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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class GroupLabDataController(
private val service: GroupLabDataService,
) {
/** Данные всех студентов группы по выбранным лабораторным — только для админа. */
@GetMapping("/api/v1/assignments/group/{groupId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun groupLabData(
@PathVariable groupId: UUID,
@RequestParam("labId") labIds: List<UUID>,
@AuthenticationPrincipal jwt: Jwt,
): GroupLabDataResponse = service.labData(groupId, labIds, jwt.tokenValue)
}

View File

@@ -0,0 +1,46 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import java.util.UUID
/**
* Агрегатор данных страницы группы: назначения, уровни, последние сдачи,
* защиты и состояние публикации по каждой лабораторной. Заменяет ~4N запросов
* BFF к сервисам одним вызовом. Без своей транзакции: внутренние сервисы
* управляют своими (внутри пишущий syncTimeouts — readOnly снаружи нельзя).
*/
@Service
class GroupLabDataService(
private val progress: ProgressService,
private val submissions: SubmissionService,
private val defenses: DefenseService,
private val publications: GradePublicationService,
) {
fun labData(
groupId: UUID,
labIds: List<UUID>,
token: String,
): GroupLabDataResponse =
GroupLabDataResponse(
groupId = groupId.toString(),
labs =
labIds.map { labId ->
LabDataResponse(
labId = labId.toString(),
assignments = progress.assignmentsForLabEnriched(labId, token),
levels =
progress.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse(
studentId = sll.studentId.toString(),
labId = sll.labId.toString(),
labLevelId = sll.labLevelId.toString(),
manual = sll.manual,
)
},
submissions = submissions.latestPerStudent(labId),
defenses = defenses.listForLab(labId),
publication = publications.state(groupId, labId),
)
},
)
}

View File

@@ -0,0 +1,24 @@
package ru.ysu.lms.progress
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/purge")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class StudentPurgeController(
private val service: StudentPurgeService,
) {
/** Полное удаление данных студента из прогресс-сервиса (включая файлы сдач). */
@DeleteMapping("/students/{studentId}")
fun purgeStudent(
@PathVariable studentId: UUID,
): Map<String, Boolean> {
service.purge(studentId)
return mapOf("ok" to true)
}
}

View File

@@ -0,0 +1,44 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.progress.repo.DefenseSessionRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.GradePublicationStudentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.SubmissionRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.util.UUID
@Suppress("LongParameterList")
@Service
class StudentPurgeService(
private val submissions: SubmissionRepository,
private val grades: LabGradeRepository,
private val gradeComments: GradeCommentRepository,
private val defenseSessions: DefenseSessionRepository,
private val assignments: VariantAssignmentRepository,
private val levels: StudentLabLevelRepository,
private val publicationsSt: GradePublicationStudentRepository,
private val storage: SubmissionStorage,
) {
/** Удаляет все данные студента в прогресс-сервисе: сдач, оценки, комментарии, защиты. */
@Transactional
fun purgeDb(studentId: UUID) {
submissions.findByStudentIdOrderByCreatedAtDesc(studentId).also { submissions.deleteAll(it) }
grades.findByStudentId(studentId).also { grades.deleteAll(it) }
gradeComments.findByStudentId(studentId).also { gradeComments.deleteAll(it) }
defenseSessions.findByStudentId(studentId).also { defenseSessions.deleteAll(it) }
assignments.findByStudentId(studentId).also { assignments.deleteAll(it) }
levels.findByStudentId(studentId).also { levels.deleteAll(it) }
publicationsSt.findByStudentId(studentId).also { publicationsSt.deleteAll(it) }
}
fun purgeFiles(studentId: UUID) = storage.deleteStudentFiles(studentId)
fun purge(studentId: UUID) {
purgeDb(studentId)
purgeFiles(studentId)
}
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@@ -8,6 +9,7 @@ import ru.ysu.lms.common.ForbiddenException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository import ru.ysu.lms.progress.repo.SubmissionRepository
import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -28,8 +30,22 @@ class SubmissionService(
val payload = validatedPayload(files) val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token)) ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId) val seq = nextSeq(studentId, labId)
val stored = storage.store(studentId, labId, seq, payload) // Сначала запись в БД: unique-констрейнт (student, lab, seq) гасит двойную отправку,
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq)) // и проигравшая попытка не трогает файлы на диске (иначе перезаписала бы предыдущую сдачу).
val entity =
try {
submissions.saveAndFlush(Submission(studentId = studentId, labId = labId, seq = seq))
} catch (ignore: DataIntegrityViolationException) {
throw BadRequestException("Эта попытка уже отправлена — обновите страницу и попробуйте ещё раз")
}
val stored =
try {
storage.store(studentId, labId, seq, payload)
} catch (ignore: IOException) {
// строка откатится вместе с транзакцией; убираем недописанные файлы
runCatching { storage.deleteAttemptDir(studentId, labId, seq) }
throw BadRequestException("Не удалось сохранить файлы решения, попробуйте ещё раз")
}
return toDto(entity, stored.files) return toDto(entity, stored.files)
} }

View File

@@ -37,12 +37,32 @@ class SubmissionStorage(
val names = mutableListOf<String>() val names = mutableListOf<String>()
for ((rawName, bytes) in files) { for ((rawName, bytes) in files) {
val safe = sanitize(rawName) val safe = sanitize(rawName)
Files.write(dir.resolve(safe), bytes) val target = dir.resolve(safe).normalize()
require(target.startsWith(dir)) { "path escape blocked" }
Files.write(target, bytes)
names.add(safe) names.add(safe)
} }
return StoredSubmission(dir, names) return StoredSubmission(dir, names)
} }
/** Страховка после сбоя записи: недописанная попытка не должна оставлять мусор на диске. */
fun deleteAttemptDir(
studentId: UUID,
labId: UUID,
seq: Int,
) {
val dir = dirFor(studentId, labId, seq).normalize()
require(dir.startsWith(rootDir) && dir != rootDir) { "path escape blocked" }
if (!Files.exists(dir)) return
Files
.walk(dir)
.use { walk ->
walk
.sorted(Comparator.reverseOrder())
.forEach { p -> runCatching { Files.delete(p) } }
}
}
/** Собирает zip из сохранённых файлов сдачи, сохраняя реальное время изменения файлов. */ /** Собирает zip из сохранённых файлов сдачи, сохраняя реальное время изменения файлов. */
fun zipOf(storedDir: Path): ByteArray { fun zipOf(storedDir: Path): ByteArray {
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
@@ -78,6 +98,22 @@ class SubmissionStorage(
seq: Int, seq: Int,
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()) ): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
/** Удаляет всю папку сдач студента (data/submissions/<studentId>/...). */
fun deleteStudentFiles(studentId: UUID) {
val dir = rootDir.resolve(studentId.toString()).normalize()
require(dir != rootDir && dir.startsWith(rootDir)) { "path escape blocked" }
if (!Files.exists(dir)) return
Files
.walk(dir)
.use { walk ->
walk
.sorted(Comparator.reverseOrder())
.forEach { p ->
runCatching { Files.delete(p) }
}
}
}
private fun sanitize(name: String): String { private fun sanitize(name: String): String {
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" } val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120) val clean = base.replace(Regex("[^A-Za-z0-9._\\-\\p{IsCyrillic}]"), "_").take(120)

View File

@@ -7,6 +7,8 @@ import java.util.UUID
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> { interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession> fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByLabIdIn(labIds: Collection<UUID>): List<DefenseSession>
fun findByStudentIdAndLabId( fun findByStudentIdAndLabId(
studentId: UUID, studentId: UUID,
labId: UUID, labId: UUID,

View File

@@ -21,4 +21,6 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
labId: UUID, labId: UUID,
studentId: UUID, studentId: UUID,
): Boolean ): Boolean
fun findByStudentId(studentId: UUID): List<GradePublicationStudent>
} }

View File

@@ -79,4 +79,46 @@ class ProgressDistributionIntegrationTest {
assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2) assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2)
assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB) assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB)
} }
@Autowired
private lateinit var groupLabData: GroupLabDataService
@Test
fun `group lab data aggregates per-lab data in one call`() {
val labId = UUID.randomUUID()
val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString()
val variantA = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo(
id = labId.toString(),
levels =
listOf(
LevelInfo(
id = "lvl",
code = "basic",
title = "Основная",
variants = listOf(VariantInfo(id = variantA, number = "1")),
),
),
),
)
service.distribute(DistributeRequest(labIds = listOf(labId.toString()), groupIds = listOf("g1")), "t")
val data = groupLabData.labData(UUID.randomUUID(), listOf(labId), "t")
assertThat(data.labs).hasSize(1)
val lab = data.labs.first()
assertThat(lab.labId).isEqualTo(labId.toString())
assertThat(lab.assignments).hasSize(2)
assertThat(lab.assignments.all { it.variantNumber == "1" }).isTrue()
assertThat(lab.defenses).isEmpty()
assertThat(lab.submissions).isEmpty()
assertThat(lab.publication.all).isFalse()
}
} }

View File

@@ -6,6 +6,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.domain.Submission
@@ -27,7 +28,7 @@ class SubmissionServiceTest {
var saved: Submission? = null var saved: Submission? = null
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null) every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
every { repo.save(any()) } answers { every { repo.saveAndFlush(any()) } answers {
firstArg<Submission>().also { saved = it } firstArg<Submission>().also { saved = it }
} }
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) } every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
@@ -73,6 +74,17 @@ class SubmissionServiceTest {
assertThat(dto.files).containsExactly("project.zip") assertThat(dto.files).containsExactly("project.zip")
} }
@Test
fun `duplicate concurrent attempt is rejected as bad request`() {
val (service, repo) = service()
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
every { repo.saveAndFlush(any()) } throws DataIntegrityViolationException("duplicate key")
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("уже отправлена")
}
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> { private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
val result = linkedMapOf<String, ByteArray>() val result = linkedMapOf<String, ByteArray>()
ZipInputStream(bytes.inputStream()).use { zin -> ZipInputStream(bytes.inputStream()).use { zin ->

View File

@@ -2,17 +2,20 @@ package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.assignDefense import ru.ysu.lms.web.api.assignDefense
import ru.ysu.lms.web.api.defenseWatch
import ru.ysu.lms.web.api.issueDefenseCode import ru.ysu.lms.web.api.issueDefenseCode
import ru.ysu.lms.web.api.resetDefense import ru.ysu.lms.web.api.resetDefense
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
@@ -24,6 +27,37 @@ class AdminDefenseController(
markdown: MarkdownRenderer, markdown: MarkdownRenderer,
mapper: ObjectMapper, mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) { ) : AdminSupport(api, markdown, mapper) {
/**
* JSON-наблюдение за активными защитами по labs=id1,id2,...
* Страница группы опрашивает его каждые ~10 секунд: таймеры синхронизируются с сервером.
*/
@GetMapping("/defense-watch")
@ResponseBody
fun defenseWatch(
@RequestParam labs: String,
session: HttpSession,
): Map<String, Any?> {
val t = token(session) ?: return mapOf("ok" to false, "error" to "Нужно войти как преподаватель")
return try {
val ids = labs.split(',').map { it.trim() }.filter { it.isNotEmpty() }
val sessions =
api.defenseWatch(t, ids).map {
mapOf(
"sessionId" to it.id,
"studentId" to it.studentId,
"labId" to it.labId,
"status" to it.status,
"code" to it.code,
"secondsLeft" to it.secondsLeft,
"readyReason" to it.readyReason,
)
}
mapOf("ok" to true, "sessions" to sessions)
} catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message)
}
}
@PostMapping("/labs/{labId}/defense/assign") @PostMapping("/labs/{labId}/defense/assign")
fun assignDefense( fun assignDefense(
@PathVariable labId: String, @PathVariable labId: String,

View File

@@ -31,10 +31,11 @@ import ru.ysu.lms.web.api.createGroup
import ru.ysu.lms.web.api.deleteCommentTemplate 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.getLevels
import ru.ysu.lms.web.api.groupCandidates import ru.ysu.lms.web.api.groupCandidates
import ru.ysu.lms.web.api.groupDetail import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.groupLabData
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.labDetail 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
@@ -66,7 +67,6 @@ class AdminGroupController(
val grades = api.listGrades(t) val grades = api.listGrades(t)
model.addAttribute("groupCount", groups.size) model.addAttribute("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount }) model.addAttribute("studentCount", groups.sumOf { it.studentCount })
model.addAttribute("groupCount", groups.size)
model.addAttribute("labCount", labs.size) model.addAttribute("labCount", labs.size)
val gradedByStudent = grades.groupBy { it.studentId } val gradedByStudent = grades.groupBy { it.studentId }
val averages = val averages =
@@ -147,9 +147,11 @@ class AdminGroupController(
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } } val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
val memberIds = group.students.map { it.id } val memberIds = group.students.map { it.id }
val memberIdSet = memberIds.toSet() val memberIdSet = memberIds.toSet()
// Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу
val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId }
val publishedByLab = val publishedByLab =
labs.associate { lab -> labs.associate { lab ->
val state = api.publicationState(t, id, lab.id) val state = dataByLab.getValue(lab.id).publication
lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet() lab.id to if (state.all) memberIdSet else state.studentIds.filter { it in memberIdSet }.toSet()
} }
val labPublication = val labPublication =
@@ -162,9 +164,9 @@ class AdminGroupController(
total = memberIds.size, total = memberIds.size,
) )
} }
val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } } val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } }
val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } } val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.associateBy { it.studentId } }
val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } } val defensesBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).defenses.associateBy { it.studentId } }
val rows = buildRows(group.students, labs, gradeBy, commentsBy, publishedByLab, assignmentsBy, submissionsBy, defensesBy) val rows = buildRows(group.students, labs, gradeBy, commentsBy, publishedByLab, assignmentsBy, submissionsBy, defensesBy)
@@ -204,6 +206,7 @@ class AdminGroupController(
defenseStatus = def?.status, defenseStatus = def?.status,
defenseCode = def?.code, defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft, defenseSecondsLeft = def?.secondsLeft,
defenseReadyReason = def?.readyReason,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true, defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id, defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd, defenseAssignmentMd = def?.assignmentMd,
@@ -278,8 +281,60 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }
/** Фрагмент большой модалки «Выдача вариантов»: вся группа одной таблицей по выбранной лабе. */
@GetMapping("/groups/{id}/assign-modal")
fun assignModal(
@PathVariable id: String,
@RequestParam labId: String,
session: HttpSession,
model: Model,
): String {
val t = token(session)
val group = api.groupDetail(t, id)
val lab = api.labDetail(t, labId)
val levels = api.getLevels(t, labId).associateBy { it.studentId }
val assignments = api.getLabAssignments(t, labId).associateBy { it.studentId }
val submissions = api.labSubmissions(t, labId).associateBy { it.studentId }
val variantUsage = java.util.HashMap<String, Int>()
assignments.values.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
val rows =
group.students.map { s ->
val assignment = assignments[s.id]
AssignModalRow(
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
levelId = levels[s.id]?.labLevelId ?: "",
variantId = assignment?.variantId,
variantNumber = assignment?.variantNumber,
manual = assignment?.manual == true,
submissionId = submissions[s.id]?.id,
submissionSeq = submissions[s.id]?.seq,
)
}
model.addAttribute("lab", lab)
model.addAttribute("groupId", id)
model.addAttribute("rows", rows)
model.addAttribute("variantUsage", variantUsage)
model.addAttribute("refreshUrl", "/admin/groups/$id/assign-modal?labId=$labId")
return "admin/assign-fragment"
}
private data class AssignModalRow(
val studentId: String,
val fullName: String,
val studentNo: String,
val levelId: String,
val variantId: String?,
val variantNumber: String?,
val manual: Boolean,
val submissionId: String?,
val submissionSeq: Int?,
)
@PostMapping("/groups/{id}/grades") @PostMapping("/groups/{id}/grades")
fun setGrade( fun setGrade(
@PathVariable id: String, @PathVariable id: String,
@@ -403,7 +458,14 @@ class AdminGroupController(
if (published) current.add(studentId) else current.remove(studentId) if (published) current.add(studentId) else current.remove(studentId)
val target = current.filter { it in memberIdSet } val target = current.filter { it in memberIdSet }
api.setPublication(t, PublicationSetPayload(id, labId, target)) api.setPublication(t, PublicationSetPayload(id, labId, target))
mapOf("ok" to true, "published" to published, "count" to target.size) val total = memberIds.size
mapOf(
"ok" to true,
"published" to published,
"count" to target.size,
"total" to total,
"all" to (total > 0 && target.size == total),
)
} catch (ex: ApiException) { } catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message) mapOf("ok" to false, "error" to ex.message)
} }

View File

@@ -16,15 +16,10 @@ import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AssignVariantPayload import ru.ysu.lms.web.api.AssignVariantPayload
import ru.ysu.lms.web.api.LabCreatePayload import ru.ysu.lms.web.api.LabCreatePayload
import ru.ysu.lms.web.api.LabDetailDto
import ru.ysu.lms.web.api.LabLevelDetailDto
import ru.ysu.lms.web.api.LabUpdatePayload import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.LevelAssignPayload import ru.ysu.lms.web.api.LevelAssignPayload
import ru.ysu.lms.web.api.LevelAssignmentResultDto
import ru.ysu.lms.web.api.LevelPayload import ru.ysu.lms.web.api.LevelPayload
import ru.ysu.lms.web.api.LevelSavePayload import ru.ysu.lms.web.api.LevelSavePayload
import ru.ysu.lms.web.api.SubmissionDto
import ru.ysu.lms.web.api.VariantAssignmentResultDto
import ru.ysu.lms.web.api.VariantSavePayload import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.addLevel import ru.ysu.lms.web.api.addLevel
import ru.ysu.lms.web.api.assignVariant import ru.ysu.lms.web.api.assignVariant
@@ -33,12 +28,9 @@ import ru.ysu.lms.web.api.deleteLab
import ru.ysu.lms.web.api.deleteLevel import ru.ysu.lms.web.api.deleteLevel
import ru.ysu.lms.web.api.deleteVariant import ru.ysu.lms.web.api.deleteVariant
import ru.ysu.lms.web.api.getLabAssignments import ru.ysu.lms.web.api.getLabAssignments
import ru.ysu.lms.web.api.getLevels
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.importLab import ru.ysu.lms.web.api.importLab
import ru.ysu.lms.web.api.labDetail 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.listGroups
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.renameLevel import ru.ysu.lms.web.api.renameLevel
import ru.ysu.lms.web.api.saveVariant import ru.ysu.lms.web.api.saveVariant
@@ -94,18 +86,25 @@ class AdminLabController(
@PostMapping("/labs/{id}/toggle-status") @PostMapping("/labs/{id}/toggle-status")
fun toggleStatus( fun toggleStatus(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
api.toggleLabStatus(token(session), id) api.toggleLabStatus(token(session), id)
redirect.addFlashAttribute("notice", "Статус лабораторной изменён") redirect.addFlashAttribute("notice", "Статус лабораторной изменён")
"redirect:/admin/labs/$id" "redirect:" + safeBack(back, "/admin/labs/$id")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$id" "redirect:/admin/labs/$id"
} }
/** Возврат на страницу, откуда пришёл запрос; принимаем только внутренние пути. */
private fun safeBack(
back: String?,
fallback: String,
): String = if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) back else fallback
@PostMapping("/labs/{id}/delete") @PostMapping("/labs/{id}/delete")
fun deleteLab( fun deleteLab(
@PathVariable id: String, @PathVariable id: String,
@@ -129,23 +128,13 @@ class AdminLabController(
): String { ): String {
val t = token(session) val t = token(session)
val lab = api.labDetail(t, id) val lab = api.labDetail(t, id)
val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId } val submissionCount = api.labSubmissions(t, id).size
val assignments = api.getLabAssignments(t, id) val assignments = api.getLabAssignments(t, id)
val assignedCount = assignments.size
val levelByStudent = api.getLevels(t, id).associateBy { it.studentId }
val assignmentByStudent = assignments.associateBy { it.studentId }
val variantUsage = java.util.HashMap<String, Int>() val variantUsage = java.util.HashMap<String, Int>()
assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 } assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
val allVariants = lab.levels.flatMap { it.variants } val allVariants = lab.levels.flatMap { it.variants }
val totalVariants = allVariants.size val totalVariants = allVariants.size
val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 } val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 }
val defaultLevel = lab.levels.firstOrNull()
val assignmentGroups =
buildAssignmentGroups(
t,
LabAssignmentData(lab, levelByStudent, assignmentByStudent, submissionsByStudent, defaultLevel),
)
model.addAttribute("lab", lab) model.addAttribute("lab", lab)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd)) model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
@@ -159,6 +148,13 @@ class AdminLabController(
ru.ysu.lms.web.DateUtils ru.ysu.lms.web.DateUtils
.formatMsk(lab.defenseAt), .formatMsk(lab.defenseAt),
) )
model.addAttribute("submissionCount", submissionCount)
model.addAttribute("totalVariants", totalVariants)
model.addAttribute("occupiedVariants", occupiedVariants)
model.addAttribute(
"variantHtml",
lab.levels.flatMap { it.variants }.associate { v -> v.id to markdown.render(v.statementMd) },
)
model.addAttribute( model.addAttribute(
"deadlineLocal", "deadlineLocal",
ru.ysu.lms.web.DateUtils ru.ysu.lms.web.DateUtils
@@ -169,57 +165,9 @@ class AdminLabController(
ru.ysu.lms.web.DateUtils ru.ysu.lms.web.DateUtils
.localInput(lab.defenseAt), .localInput(lab.defenseAt),
) )
model.addAttribute("assignedCount", assignedCount)
model.addAttribute("submissionCount", submissionsByStudent.size)
model.addAttribute("assignmentGroups", assignmentGroups)
model.addAttribute("groups", api.listGroups(t))
model.addAttribute("variantUsage", variantUsage)
model.addAttribute("totalVariants", totalVariants)
model.addAttribute("occupiedVariants", occupiedVariants)
model.addAttribute("freeVariants", totalVariants - occupiedVariants)
return "admin/lab" return "admin/lab"
} }
private data class LabAssignmentData(
val lab: LabDetailDto,
val levelByStudent: Map<String, LevelAssignmentResultDto>,
val assignmentByStudent: Map<String, VariantAssignmentResultDto>,
val submissionsByStudent: Map<String, SubmissionDto>,
val defaultLevel: LabLevelDetailDto?,
)
/** Строки «группа → студенты» со их уровнем, вариантом и последней сдачей. */
private fun buildAssignmentGroups(
token: String,
data: LabAssignmentData,
): List<AdminLabGroup> =
api.listGroups(token).map { g ->
val detail = api.groupDetail(token, g.id)
AdminLabGroup(
groupName = g.name,
students =
detail.students.map { s ->
val levelState = data.levelByStudent[s.id]
val level = data.lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: data.defaultLevel
val va = data.assignmentByStudent[s.id]
AdminLabStudent(
studentId = s.id,
fullName = s.fullName,
studentNo = s.studentNo,
levelId = level?.id ?: "",
levelTitle = level?.title ?: "—",
levelChanged = levelState?.manual == true,
variantId = va?.variantId,
variantNumber = va?.variantNumber,
source = if (va?.manual == true) "вручную" else "авто",
submissionId = data.submissionsByStudent[s.id]?.id,
submissionSeq = data.submissionsByStudent[s.id]?.seq,
submissionFiles = data.submissionsByStudent[s.id]?.files ?: emptyList(),
)
},
)
}
@PostMapping("/labs/{labId}/assign") @PostMapping("/labs/{labId}/assign")
fun assignVariant( fun assignVariant(
@PathVariable labId: String, @PathVariable labId: String,
@@ -320,24 +268,23 @@ class AdminLabController(
"redirect:/admin/labs/$labId" "redirect:/admin/labs/$labId"
} }
@GetMapping("/labs/{id}/edit")
/** Быстрые действия «удалить уровень/вариант» на форме лабы; null — обычное сохранение. */ /** Быстрые действия «удалить уровень/вариант» на форме лабы; null — обычное сохранение. */
private fun handleEditDeleteAction( private fun handleEditDeleteAction(
token: String, token: String,
id: String, id: String,
action: String, action: String?,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String? = ): String? =
when { when {
action.startsWith("delete-level-") -> { action.orEmpty().startsWith("delete-level-") -> {
api.deleteLevel(token, id, action.removePrefix("delete-level-")) api.deleteLevel(token, id, action.orEmpty().removePrefix("delete-level-"))
redirect.addFlashAttribute("notice", "Уровень удалён") redirect.addFlashAttribute("notice", "Уровень удалён")
"redirect:/admin/labs/$id/edit" "redirect:/admin/labs/$id"
} }
action.startsWith("delete-variant-") -> { action.orEmpty().startsWith("delete-variant-") -> {
api.deleteVariant(token, id, action.removePrefix("delete-variant-")) api.deleteVariant(token, id, action.orEmpty().removePrefix("delete-variant-"))
redirect.addFlashAttribute("notice", "Вариант удалён") redirect.addFlashAttribute("notice", "Вариант удалён")
"redirect:/admin/labs/$id/edit" "redirect:/admin/labs/$id"
} }
else -> null else -> null
} }
@@ -392,27 +339,6 @@ class AdminLabController(
} }
} }
fun editLabForm(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
val lab = api.labDetail(token(session), id)
// datetime-local требует формат yyyy-MM-dd'T'HH:mm
model.addAttribute("lab", lab)
model.addAttribute(
"deadlineLocal",
ru.ysu.lms.web.DateUtils
.localInput(lab.deadlineAt),
)
model.addAttribute(
"defenseLocal",
ru.ysu.lms.web.DateUtils
.localInput(lab.defenseAt),
)
return "admin/lab-edit"
}
/** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */ /** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */
@PostMapping("/labs/{id}/edit") @PostMapping("/labs/{id}/edit")
fun editLab( fun editLab(
@@ -462,10 +388,10 @@ class AdminLabController(
} }
else -> redirect.addFlashAttribute("notice", "Все изменения сохранены") else -> redirect.addFlashAttribute("notice", "Все изменения сохранены")
} }
"redirect:/admin/labs/$id/edit" "redirect:/admin/labs/$id"
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$id/edit" "redirect:/admin/labs/$id"
} }
} }

View File

@@ -1,31 +1,5 @@
package ru.ysu.lms.web.admin package ru.ysu.lms.web.admin
data class AdminLabGroup(
val groupName: String,
val students: List<AdminLabStudent>,
)
data class AdminLabStudent(
val studentId: String,
val fullName: String,
val studentNo: String,
val levelId: String,
val levelTitle: String,
val levelChanged: Boolean,
val variantId: String?,
val variantNumber: String?,
val source: String,
val submissionId: String? = null,
val submissionSeq: Int? = null,
val submissionFiles: List<String> = emptyList(),
val defenseStatus: String? = null,
val defenseCode: String? = null,
val defenseSessionId: String? = null,
val defenseSecondsLeft: Long? = null,
val defenseAssigned: Boolean = false,
val adminAssignmentMd: String = "",
)
data class AdminStudentGrades( data class AdminStudentGrades(
val studentId: String, val studentId: String,
val fullName: String, val fullName: String,
@@ -58,6 +32,7 @@ data class GroupLabCell(
val defenseStatus: String? = null, val defenseStatus: String? = null,
val defenseCode: String? = null, val defenseCode: String? = null,
val defenseSecondsLeft: Long? = null, val defenseSecondsLeft: Long? = null,
val defenseReadyReason: String? = null,
val defenseAssigned: Boolean = false, val defenseAssigned: Boolean = false,
val defenseSessionId: String? = null, val defenseSessionId: String? = null,
val defenseAssignmentMd: String? = null, val defenseAssignmentMd: String? = null,

View File

@@ -19,6 +19,7 @@ import ru.ysu.lms.web.api.CreateStudentRequestDto
import ru.ysu.lms.web.api.createStudent import ru.ysu.lms.web.api.createStudent
import ru.ysu.lms.web.api.credentials import ru.ysu.lms.web.api.credentials
import ru.ysu.lms.web.api.enrollStudent import ru.ysu.lms.web.api.enrollStudent
import ru.ysu.lms.web.api.groupDetail
import ru.ysu.lms.web.api.resetPassword import ru.ysu.lms.web.api.resetPassword
import ru.ysu.lms.web.api.unenrollStudent import ru.ysu.lms.web.api.unenrollStudent
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
@@ -42,13 +43,25 @@ class AdminStudentController(
try { try {
val result = api.resetPassword(token(session), studentId) val result = api.resetPassword(token(session), studentId)
redirect.addFlashAttribute("resetPassword", result.password) redirect.addFlashAttribute("resetPassword", result.password)
redirect.addFlashAttribute("resetFor", studentId) // В тосте должно быть видно, ЧЬЙ пароль: за сеанс можно сбросить несколько подряд.
redirect.addFlashAttribute("resetFor", studentName(token(session), id, studentId))
"redirect:/admin/groups/$id" "redirect:/admin/groups/$id"
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id" "redirect:/admin/groups/$id"
} }
private fun studentName(
token: String,
groupId: String,
studentId: String,
): String =
runCatching { api.groupDetail(token, groupId).students }
.getOrNull()
?.firstOrNull { it.id == studentId }
?.fullName
?: studentId
@GetMapping("/groups/{id}/credentials") @GetMapping("/groups/{id}/credentials")
fun credentials( fun credentials(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -0,0 +1,93 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.deleteStudent
import ru.ysu.lms.web.api.purgeStudent
import ru.ysu.lms.web.api.searchStudents
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
import tools.jackson.databind.ObjectMapper
@Controller
@RequestMapping("/admin/users")
class AdminUsersController(
api: ApiClient,
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : AdminSupport(api, markdown, mapper) {
@GetMapping
fun list(
@RequestParam(required = false, name = "q") query: String?,
session: HttpSession,
model: Model,
): String {
val users = runCatching { api.searchStudents(token(session), query) }.getOrDefault(emptyList())
model.addAttribute("users", users)
model.addAttribute("query", query.orEmpty())
return "admin/users"
}
/** Удаляет пользователя и все его данные (сдачи с файлами, оценки, комментарии, защиты, варианты). */
@PostMapping("/{id}/delete")
fun delete(
@PathVariable id: String,
@RequestParam(required = false, name = "q") query: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
val current = session.getAttribute(SessionKeys.USER) as? SessionUser
if (current != null && current.id == id) {
redirect.addFlashAttribute("error", "Нельзя удалить собственный аккаунт")
return redirectTo(query)
}
return try {
val t = token(session)
val student = api.searchStudents(t, null).firstOrNull { it.id == id }
// Сначала аккаунт, потом очистка данных: сбой очистки оставит безвредные
// данные-сироты (дочищаются повторным запуском), а не «живой» аккаунт без данных.
val deleted =
try {
api.deleteStudent(t, id)
} catch (alreadyGone: ApiException) {
if (alreadyGone.status != 404) throw alreadyGone
null // аккаунта уже нет — дочищаем данные после прошлого сбоя
}
val name =
(deleted?.fullName?.ifBlank { student?.fullName ?: deleted.login } ?: student?.fullName ?: id)
try {
api.purgeStudent(t, id)
} catch (purgeError: ApiException) {
redirect.addFlashAttribute(
"error",
"Аккаунт $name удалён, но данные прогресса не очищены (${purgeError.message}). " +
"Нажмите «Удалить» ещё раз, чтобы дочистить.",
)
return redirectTo(query)
}
redirect.addFlashAttribute("notice", "Пользователь $name удалён вместе со всеми данными")
redirectTo(query)
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
redirectTo(query)
}
}
private fun redirectTo(query: String?): String =
if (query.isNullOrBlank()) {
"redirect:/admin/users"
} else {
val encoded = java.net.URLEncoder.encode(query, java.nio.charset.StandardCharsets.UTF_8)
"redirect:/admin/users?q=$encoded"
}
}

View File

@@ -2,18 +2,33 @@ package ru.ysu.lms.web.api
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException import org.springframework.web.client.RestClientResponseException
import java.net.URLDecoder import java.net.URLDecoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Duration
@Component @Component
class ApiClient( class ApiClient(
@Value("\${lms.gateway.base-url}") baseUrl: String, @Value("\${lms.gateway.base-url}") baseUrl: String,
) { ) {
internal val client: RestClient = RestClient.builder().baseUrl(baseUrl).build() // readTimeout большой: через BFF ходят загрузки/выгрузки до 200 МБ (импорт ZIP, архивы сдач).
internal val client: RestClient =
RestClient
.builder()
.baseUrl(baseUrl)
.requestFactory(
JdkClientHttpRequestFactory(
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build(),
).apply { setReadTimeout(Duration.ofSeconds(300)) },
).build()
internal fun bearer(token: String): String = "Bearer $token" internal fun bearer(token: String): String = "Bearer $token"

View File

@@ -19,6 +19,22 @@ fun ApiClient.labDefenses(
} }
} }
/** Наблюдение за сессиями защиты по списку лабораторных (для периодической синхронизации таймеров). */
fun ApiClient.defenseWatch(
token: String,
labIds: List<String>,
): List<DefenseDto> {
val type = object : ParameterizedTypeReference<List<DefenseDto>>() {}
return call {
client
.get()
.uri("/api/v1/defenses/watch?labs={labs}", labIds.joinToString(","))
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.assignDefense( fun ApiClient.assignDefense(
token: String, token: String,
studentId: String, studentId: String,
@@ -88,6 +104,20 @@ fun ApiClient.myDefense(
.body(DefenseDto::class.java) .body(DefenseDto::class.java)
} }
/** Компактный статус защиты для синхронизации таймера на странице студента. */
fun ApiClient.myDefenseStatus(
token: String,
labId: String,
): Map<*, *> =
call {
client
.get()
.uri("/api/v1/me/defenses/{labId}/status", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(Map::class.java)
}
fun ApiClient.startDefense( fun ApiClient.startDefense(
token: String, token: String,
code: String, code: String,

View File

@@ -0,0 +1,25 @@
package ru.ysu.lms.web.api
import org.springframework.http.HttpHeaders
/** Данные группы по всем лабораторным одним запросом (назначения, уровни, сдачи, защиты, публикация). */
fun ApiClient.groupLabData(
token: String,
groupId: String,
labIds: List<String>,
): GroupLabDataDto =
call {
val spec =
client
.get()
.uri { builder ->
builder
.path("/api/v1/assignments/group/{groupId}")
.queryParam("labId", *labIds.toTypedArray())
.build(groupId)
}
spec
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(GroupLabDataDto::class.java)
}

View File

@@ -134,6 +134,36 @@ fun ApiClient.resetPassword(
.body(ResetPasswordDto::class.java) .body(ResetPasswordDto::class.java)
} }
/** Поиск пользователей (студентов) для страницы управления. */
fun ApiClient.searchStudents(
token: String,
query: String?,
): List<AdminUserDto> {
val type = object : ParameterizedTypeReference<List<AdminUserDto>>() {}
return call {
client
.get()
.uri { builder ->
builder.path("/api/v1/students").queryParam("q", query.orEmpty()).build()
}.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(type)
}
}
fun ApiClient.deleteStudent(
token: String,
studentId: String,
): DeleteUserDto =
call {
client
.delete()
.uri("/api/v1/students/{id}", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(DeleteUserDto::class.java)
}
fun ApiClient.credentials( fun ApiClient.credentials(
token: String, token: String,
groupId: String, groupId: String,

View File

@@ -271,3 +271,16 @@ fun ApiClient.downloadSubmissionAsAdmin(
} }
return downloadOf(entity) return downloadOf(entity)
} }
fun ApiClient.purgeStudent(
token: String,
studentId: String,
): StudentPurgeResultDto =
call {
client
.delete()
.uri("/api/v1/purge/students/{id}", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(StudentPurgeResultDto::class.java)
}

View File

@@ -89,6 +89,30 @@ data class StudentCandidateDto(
val currentGroupName: String? = null, val currentGroupName: String? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class AdminUserDto(
val id: String = "",
val login: String = "",
val role: String = "STUDENT",
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
val groupId: String? = null,
val groupName: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeleteUserDto(
val id: String = "",
val login: String = "",
val fullName: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentPurgeResultDto(
val ok: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto( data class ResetPasswordDto(
val password: String = "", val password: String = "",
@@ -453,3 +477,19 @@ data class DefenseDto(
) { ) {
var assignmentHtml: String = "" var assignmentHtml: String = ""
} }
@JsonIgnoreProperties(ignoreUnknown = true)
data class LabDataDto(
val labId: String = "",
val assignments: List<VariantAssignmentResultDto> = emptyList(),
val levels: List<LevelAssignmentResultDto> = emptyList(),
val submissions: List<SubmissionDto> = emptyList(),
val defenses: List<DefenseDto> = emptyList(),
val publication: PublicationStateDto = PublicationStateDto(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupLabDataDto(
val groupId: String = "",
val labs: List<LabDataDto> = emptyList(),
)

View File

@@ -1,9 +1,11 @@
package ru.ysu.lms.web.config package ru.ysu.lms.web.config
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import ru.ysu.lms.web.session.AdminInterceptor import ru.ysu.lms.web.session.AdminInterceptor
@@ -12,6 +14,18 @@ import ru.ysu.lms.web.session.MustChangePasswordInterceptor
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser import ru.ysu.lms.web.session.SessionUser
/** Динамические страницы не кэшируются: браузер всегда берёт свежую разметку со свежими ссылками на css/js. */
class NoCachePageInterceptor : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
response.setHeader("Cache-Control", "no-store")
return true
}
}
@Configuration @Configuration
class WebConfig( class WebConfig(
private val authInterceptor: AuthInterceptor, private val authInterceptor: AuthInterceptor,
@@ -21,6 +35,10 @@ class WebConfig(
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**") registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry
.addInterceptor(NoCachePageInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/js/**", "/favicon.svg", "/error")
registry registry
.addInterceptor(mustChangePasswordInterceptor) .addInterceptor(mustChangePasswordInterceptor)
.addPathPatterns("/**") .addPathPatterns("/**")

View File

@@ -24,9 +24,15 @@ class ContentAssetProxy(
val media = val media =
runCatching { MediaType.parseMediaType(asset.contentType) } runCatching { MediaType.parseMediaType(asset.contentType) }
.getOrElse { MediaType.APPLICATION_OCTET_STREAM } .getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity val builder =
.ok() ResponseEntity
.contentType(media) .ok()
.body(asset.bytes) .contentType(media)
// Проксируем аккуратно: SVG при прямом открытии не должен исполнять скрипты.
if (media.isCompatibleWith(MediaType.parseMediaType("image/svg+xml"))) {
builder.header("Content-Security-Policy", "script-src 'none'; object-src 'none'")
}
builder.header("X-Content-Type-Options", "nosniff")
return builder.body(asset.bytes)
} }
} }

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.markdown.MarkdownRenderer import ru.ysu.lms.markdown.MarkdownRenderer
@@ -24,6 +25,7 @@ import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.listLabs
import ru.ysu.lms.web.api.meAssignments import ru.ysu.lms.web.api.meAssignments
import ru.ysu.lms.web.api.myDefense import ru.ysu.lms.web.api.myDefense
import ru.ysu.lms.web.api.myDefenseStatus
import ru.ysu.lms.web.api.myGrades import ru.ysu.lms.web.api.myGrades
import ru.ysu.lms.web.api.myGroup import ru.ysu.lms.web.api.myGroup
import ru.ysu.lms.web.api.mySubmissions import ru.ysu.lms.web.api.mySubmissions
@@ -188,6 +190,22 @@ class StudentController(
} }
} }
/** Компактный статус защиты для синхронизации таймера (~раз в 10 с). */
@GetMapping("/labs/{id}/defense-status")
@ResponseBody
fun defenseStatus(
@PathVariable id: String,
session: HttpSession,
): Map<String, Any?> {
val t = token(session) ?: return mapOf("ok" to false)
return try {
val s = api.myDefenseStatus(t, id)
mapOf("ok" to true, "status" to s["status"], "secondsLeft" to s["secondsLeft"], "readyReason" to s["readyReason"])
} catch (ex: ApiException) {
mapOf("ok" to false, "error" to ex.message)
}
}
@GetMapping("/submissions/{id}/download") @GetMapping("/submissions/{id}/download")
fun download( fun download(
@PathVariable id: String, @PathVariable id: String,

View File

@@ -8,6 +8,9 @@ server:
cookie: cookie:
http-only: true http-only: true
same-site: lax same-site: lax
# Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры
# тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false.
secure: ${LMS_SESSION_COOKIE_SECURE:true}
max-age: 30d max-age: 30d
tracking-modes: cookie tracking-modes: cookie

View File

@@ -7,44 +7,62 @@ body {
flex-direction: column; flex-direction: column;
background: var(--c-bg); background: var(--c-bg);
color: var(--c-text); color: var(--c-text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.55;
-webkit-font-smoothing: antialiased;
} }
/* Палитра Б3: нейтральный каркас + бордовый акцент.
Цвет несёт смысл: акцент — действия и фокус, зелёный/янтарный — статусы защиты,
винный — деструктивные действия и негативная тональность. */
:root { :root {
--c-bg: #f8fafc; --c-bg: #fafafa;
--c-surface: #ffffff; --c-surface: #ffffff;
--c-text: #0f172a; --c-text: #131316;
--c-text-muted: #64748b; --c-text-muted: #74747c;
--c-text-link: #6366f1; --c-text-link: #131316;
--c-text-link-hover: #4f46e5; --c-text-link-hover: #7a2333;
--c-border: #e2e8f0; --c-border: #e6e6e9;
--c-accent: #6366f1; --c-border-strong: #d4d4da;
--c-badge-ok-bg: #dcfce7; --c-accent: #7a2333;
--c-badge-ok-text: #166534; --c-accent-hover: #5f1b28;
--c-badge-warn-bg: #fef3c7; --c-accent-soft: #f7edee;
--c-badge-warn-text: #92400e; --c-badge-ok-bg: #f2f7f3;
--c-tone-pos: #16803c; --c-badge-ok-text: #2f6b3a;
--c-tone-neg: #b3283b; --c-badge-ok-border: #b7cdbd;
--c-tone-neu: #64748b; --c-badge-warn-bg: #faf5e9;
--c-badge-warn-text: #8a5a12;
--c-badge-warn-border: #d3bd8f;
--c-tone-pos: #2f6b3a;
--c-tone-neg: #9d2b3c;
--c-tone-neu: #74747c;
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-lg: 0.625rem;
} }
[data-theme="dark"] { [data-theme="dark"] {
--c-bg: #0f1115; --c-bg: #101014;
--c-surface: #171a21; --c-surface: #17181d;
--c-text: #e7e9ee; --c-text: #e8e9ec;
--c-text-muted: #9aa3b2; --c-text-muted: #9a9ba4;
--c-text-link: #6ea8fe; --c-text-link: #e8e9ec;
--c-text-link-hover: #8bb9fe; --c-text-link-hover: #d98b98;
--c-border: #262b36; --c-border: #26272e;
--c-accent: #6ea8fe; --c-border-strong: #34353d;
--c-badge-ok-bg: rgba(16, 185, 129, 0.15); --c-accent: #a64d5f;
--c-badge-ok-text: #6ee7b7; --c-accent-hover: #b86070;
--c-badge-warn-bg: rgba(251, 191, 36, 0.15); --c-accent-soft: rgba(166, 77, 95, 0.16);
--c-badge-warn-text: #fcd34d; --c-badge-ok-bg: rgba(46, 107, 58, 0.25);
--c-tone-pos: #4ade80; --c-badge-ok-text: #8fd4a1;
--c-tone-neg: #f87171; --c-badge-ok-border: rgba(143, 212, 161, 0.4);
--c-tone-neu: #9aa3b2; --c-badge-warn-bg: rgba(138, 90, 18, 0.25);
--c-badge-warn-text: #e3b766;
--c-badge-warn-border: rgba(227, 183, 102, 0.4);
--c-tone-pos: #6fcf8f;
--c-tone-neg: #d98b98;
--c-tone-neu: #9a9ba4;
} }
a { color: var(--c-text-link); text-decoration: none; } a { color: var(--c-text-link); text-decoration: none; }
@@ -56,59 +74,74 @@ a:not(.btn):hover { color: var(--c-text-link-hover); }
/* Layout */ /* Layout */
.page { display: flex; flex-direction: column; min-height: 100vh; } .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 { 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; gap: 0.5rem 1rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; } .page-header-inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 0.5rem 1.75rem; flex-wrap: wrap; padding: 0.875rem 1.75rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
.page-header-inner { padding: 0.5rem 0.75rem; } .page-header-inner { padding: 0.5rem 0.875rem; gap: 0.5rem 1rem; }
.nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; } .nav { gap: 0.5rem 0.75rem; flex-wrap: wrap; }
.nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; } .user { gap: 0.5rem; }
.nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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-main { flex: 1; width: 100%; max-width: 1200px; margin: 0 auto; padding: 2.5rem 1.75rem 3.5rem; }
.page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); } @media (max-width: 720px) { .page-main { padding: 1.5rem 0.875rem 2.5rem; } }
.page-footer { text-align: center; color: var(--c-text-muted); font-size: 0.75rem; padding: 1.5rem; }
/* Nav */ /* Nav */
.nav { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; } .nav { display: flex; align-items: center; gap: 1.125rem; flex: 1 1 auto; flex-wrap: wrap; font-size: 0.84375rem; }
.nav-link { color: var(--c-text-link); text-decoration: none; } .nav-link { color: var(--c-text-muted); text-decoration: none; font-weight: 500; }
.nav-link:hover { color: var(--c-text-link-hover); } .nav-link:hover { color: var(--c-text); }
.nav-link.active { color: var(--c-accent); }
.nav-sep { color: var(--c-text-muted); } .nav-sep { color: var(--c-text-muted); }
.logo { font-size: 1.125rem; font-weight: 600; color: var(--c-text); text-decoration: none; } .logo { font-size: 1rem; font-weight: 700; color: var(--c-text); text-decoration: none; letter-spacing: -0.01em; }
.nav-user { font-weight: 500; } .logo b { color: var(--c-accent); }
.user { display: flex; align-items: center; gap: 0.75rem; font-size: 0.84375rem; margin-left: auto; }
.nav-user { font-weight: 500; color: var(--c-text); }
.nav-user:hover { color: var(--c-accent); }
.nav-user.defense-active { color: var(--c-accent); }
.nav-user.defense-active::before { content: ""; display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--c-accent); margin-right: 0.4rem; vertical-align: middle; }
.score-big { display: inline-block; margin-left: 0.5rem; font-size: 1.75rem; font-weight: 700; line-height: 1; color: var(--c-tone-pos); font-variant-numeric: tabular-nums; vertical-align: middle; }
.variant-name { font-weight: 600; }
.variant-name b { font-variant-numeric: tabular-nums; }
p { margin-top: 0; }
.theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; } .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; } .theme-icon { width: 1.05rem; height: 1.05rem; display: block; }
.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } .breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.breadcrumb .nav-sep { user-select: none; } .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; } .check-list { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; max-height: 12rem; overflow-y: auto; }
/* Buttons */ /* Buttons — по спецификации Б3: основная кнопка залита акцентом,
вторичная и опасная — контурные. Компактный кегль 13px. */
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
border-radius: 0.5rem; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--c-accent); border-radius: var(--radius);
border: none; cursor: pointer; transition: opacity 0.15s; background: var(--c-accent); color: #ffffff;
padding: 0.5rem 1rem; font-size: 0.8125rem; font-weight: 500;
cursor: pointer; text-decoration: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
} }
.btn:active { opacity: 0.8; } .btn:active { opacity: 0.85; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .btn:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.btn-primary { background: var(--c-accent); color: white; text-decoration: none; } .btn:hover { background: var(--c-accent-hover); border-color: var(--c-accent-hover); }
.btn-primary:hover { opacity: 0.9; } .btn-primary { background: var(--c-accent); border-color: var(--c-accent); color: #ffffff; }
.btn-secondary { .btn-primary:hover { background: var(--c-accent-hover); border-color: var(--c-accent-hover); }
background: var(--c-surface); color: var(--c-text); text-decoration: none; .btn-secondary { background: var(--c-surface); border-color: var(--c-border-strong); color: var(--c-text); }
box-shadow: inset 0 0 0 1px var(--c-border); .btn-secondary:hover { background: var(--c-bg); border-color: var(--c-border-strong); color: var(--c-text); }
} .btn-danger { background: var(--c-surface); border-color: #dcb9be; color: var(--c-accent); }
.btn-secondary:hover { background: var(--c-border); } .btn-danger:hover { background: var(--c-accent-soft); border-color: var(--c-accent); }
/* Forms */ /* Forms */
.input { .input {
width: 100%; border-radius: 0.5rem; background: var(--c-bg); color: var(--c-text); width: 100%; border-radius: var(--radius); background: var(--c-surface); color: var(--c-text);
padding: 0.5rem 0.75rem; font-size: 0.875rem; border: 1px solid var(--c-border); padding: 0.5rem 0.75rem; font-size: 0.875rem; border: 1px solid var(--c-border-strong);
outline: none; outline: none;
} }
.input:focus { border-color: var(--c-accent); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); } .input:focus { border-color: var(--c-accent); box-shadow: 0 0 0 3px var(--c-accent-soft); }
.input::placeholder { color: var(--c-text-muted); } .input::placeholder { color: var(--c-text-muted); }
.label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; } .label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; }
.hint { margin-top: 0.25rem; font-size: 0.75rem; color: var(--c-text-muted); } .hint { margin-top: 0.25rem; font-size: 0.75rem; color: var(--c-text-muted); }
/* Cards */ /* Cards */
.card { border-radius: 0.75rem; background: var(--c-surface); border: 1px solid var(--c-border); } .card { border-radius: var(--radius-lg); background: var(--c-surface); border: 1px solid var(--c-border); }
.card-flush { border-radius: 0.75rem; background: var(--c-surface); border: 1px solid var(--c-border); padding: 0; overflow: hidden; } .card-flush { border-radius: var(--radius-lg); background: var(--c-surface); border: 1px solid var(--c-border); padding: 0; overflow: hidden; }
.card-body { padding: 1.5rem; } .card-body { padding: 1.5rem; }
/* Grid */ /* Grid */
@@ -117,47 +150,74 @@ a:not(.btn):hover { color: var(--c-text-link-hover); }
.grid-stats { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } .grid-stats { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
/* Typography */ /* Typography */
.page-title { margin: 0 0 1.5rem; font-size: 1.5rem; font-weight: 600; } .page-title { margin: 0 0 1.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
.section-title { margin: 0 0 0.75rem; font-size: 1.125rem; font-weight: 500; } .section-title { margin: 0 0 0.75rem; font-size: 1.125rem; font-weight: 500; }
/* Шапка страницы в стиле Б3: kicker + заголовок + мета, действия справа */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.75rem; flex-wrap: wrap; }
.page-head .page-title { margin: 0 0 0.5rem; }
.kicker { margin: 0 0 0.375rem; font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted); }
.meta { margin: 0; color: var(--c-text-muted); font-size: 0.8125rem; }
.meta b { color: var(--c-text); font-weight: 600; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.stat-value { margin: 0.5rem 0 0; font-size: 1.875rem; font-weight: 600; } .stat-value { margin: 0.5rem 0 0; font-size: 1.875rem; font-weight: 600; }
.stat-title { font-size: 0.875rem; color: var(--c-text-muted); margin: 0; } .stat-title { font-size: 0.875rem; color: var(--c-text-muted); margin: 0; }
.text-muted { color: var(--c-text-muted); } /* ---- Utilities: плоские классы поверх компонентов (в духе Bootstrap) ---- */
.opacity-30 { opacity: 0.3; } .flex { display: flex; }
.text-center { text-align: center; } .flex-wrap { flex-wrap: wrap; }
.text-sm { font-size: 0.875rem; } .items-center { align-items: center; }
.font-mono { font-family: monospace; } .items-start { align-items: flex-start; }
.justify-between { justify-content: space-between; }
/* Spacing */ .justify-center { justify-content: center; }
.m-0 { margin: 0; }
.mt-1 { margin-top: 0.25rem; } .mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; } .mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; } .mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; } .mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; } .mt-8 { margin-top: 2rem; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; } .mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; } .mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; } .mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; } .ml-2 { margin-left: 0.5rem; }
.ml-auto { margin-left: auto; }
.p-0 { padding: 0; } .p-0 { padding: 0; }
.p-4 { padding: 1rem; } .p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; } .p-6 { padding: 1.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; } .gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; } .gap-4 { gap: 1rem; }
/* Flex */
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.w-full { width: 100%; } .w-full { width: 100%; }
.w-260 { width: 260px; }
.overflow-x-auto { overflow-x: auto; } .overflow-x-auto { overflow-x: auto; }
.text-sm { font-size: 0.875rem; }
.text-muted { color: var(--c-text-muted); }
.text-strong { font-weight: 600; color: var(--c-text); }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-break { overflow-wrap: anywhere; }
.font-mono { font-family: monospace; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.opacity-30 { opacity: 0.3; }
.offscreen { position: fixed; top: -1000px; left: 0; }
/* Badges */ /* Badges — «этикетки»: строгая рамка, разрядка капсом; только индикаторы статуса */
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 500; } .badge {
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); } display: inline-flex; align-items: center;
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); } border-radius: var(--radius-xs); padding: 0.1rem 0.5rem;
.badge-btn { border: none; cursor: pointer; padding: 0.125rem 0.625rem; } font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
border: 1px solid transparent;
}
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); border-color: var(--c-badge-ok-border); }
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); border-color: var(--c-badge-warn-border); }
/* Tables */ /* Tables */
table { width: 100%; font-size: 0.875rem; border-collapse: collapse; } table { width: 100%; font-size: 0.875rem; border-collapse: collapse; }
@@ -168,27 +228,24 @@ td { padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--c-border); }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
/* Alerts */ /* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 0.5rem; font-size: 0.875rem; margin-bottom: 1rem; } .alert { padding: 0.75rem 1rem; border-radius: var(--radius); font-size: 0.875rem; margin-bottom: 1rem; }
.alert-error { background: #fef2f2; color: #991b1b; } .alert-error { background: #fef2f2; color: #991b1b; }
.alert-success { background: #dcfce7; color: #166534; } .alert-success { background: #dcfce7; color: #166534; }
.alert-warning { background: #fef3c7; color: #92400e; } .alert-warning { background: #fef3c7; color: #92400e; }
.ml-2 { margin-left: 0.5rem; }
.list-pad { padding-left: 1.25rem; } .list-pad { padding-left: 1.25rem; }
.import-card { border-color: rgba(16,185,129,0.4); } .import-card { border-color: var(--c-badge-ok-border); }
.mini-alert { padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 0.5rem; } .mini-alert { padding: 0.5rem 0.75rem; border-radius: var(--radius-xs); margin-bottom: 0.5rem; }
.font-semibold { font-weight: 600; }
/* Theme toggle */ /* Theme toggle */
.theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* Group assignment page */ /* Group assignment page */
.group-title { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; } .group-title { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
.inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; } .inline-form { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; }
.inline-form .input-sm { width: auto; min-width: 10rem; } /* Селект фокуса на лабу в тулбаре ведомости — по содержимому, не на всю ширину */
.input-sm { padding: 0.25rem 0.5rem; font-size: 0.8125rem; } #lab-focus { width: auto; min-width: 11rem; }
.input-score { width: 4.5rem; } .input-score { width: 4.5rem; }
.inline-form .input-sm { width: auto; min-width: 0; }
.inline-form .input-score, input[type="number"].input-score { width: 4.5rem; min-width: 4.5rem; flex: 0 0 auto; } .inline-form .input-score, input[type="number"].input-score { width: 4.5rem; min-width: 4.5rem; flex: 0 0 auto; }
.filter-select { width: auto; } .filter-select { width: auto; }
.hidden { display: none !important; } .hidden { display: none !important; }
@@ -207,40 +264,45 @@ tr:last-child td { border-bottom: none; }
.toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; } .toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; }
/* Всплывающие уведомления (flash notice/error) вместо статичных алертов */ /* Всплывающие уведомления (flash notice/error) вместо статичных алертов */
/* Уведомления: внизу по центру, в стиле Б3 (поверх .notice) */
.toast-host { .toast-host {
position: fixed; top: 1rem; right: 1rem; z-index: 100; position: fixed; left: 50%; transform: translateX(-50%); bottom: 1.5rem; z-index: 100;
display: flex; flex-direction: column; gap: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; align-items: stretch;
width: min(24rem, calc(100vw - 2rem)); pointer-events: none; width: min(34rem, calc(100vw - 2rem)); pointer-events: none;
} }
.toast-item { .toast-item {
pointer-events: auto; display: flex; align-items: flex-start; gap: 0.5rem; pointer-events: auto; display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.7rem 0.85rem; border-radius: 0.5rem; border: 1px solid var(--c-border); padding: 0.75rem 1rem; border-radius: var(--radius); border: 1px solid var(--c-border);
border-left-width: 4px; background: var(--c-surface); color: var(--c-text); border-left: 3px solid var(--c-accent); background: var(--c-surface); color: var(--c-text);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); font-size: 0.8125rem;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
animation: toast-in 0.18s ease-out; animation: toast-in 0.18s ease-out;
} }
.toast-item .toast-text { flex: 1 1 auto; min-width: 0; white-space: pre-line; overflow-wrap: anywhere; } .toast-item .toast-text { flex: 1 1 auto; min-width: 0; white-space: pre-line; overflow-wrap: anywhere; }
.toast-item.toast-success { border-left-color: #16a34a; } .toast-item .toast-text .code { margin: 0 0.1rem; }
.toast-item.toast-error { border-left-color: #dc2626; } .toast-item.toast-success { border-left-color: var(--c-tone-pos); background: var(--c-badge-ok-bg); }
.toast-item.toast-warning { border-left-color: #d97706; } .toast-item.toast-error { border-left-color: var(--c-accent); background: var(--c-accent-soft); }
.toast-item.toast-warning { border-left-color: var(--c-badge-warn-border); background: var(--c-badge-warn-bg); }
.toast-close { .toast-close {
flex: 0 0 auto; border: none; background: none; cursor: pointer; flex: 0 0 auto; border: none; background: none; cursor: pointer;
color: var(--c-text-muted); font-size: 1.05rem; line-height: 1; padding: 0 0.1rem; color: var(--c-text-muted); font-size: 1rem; line-height: 1; padding: 0 0.1rem;
} }
.toast-close:hover { color: var(--c-text); } .toast-close:hover { color: var(--c-text); }
@keyframes toast-in { @keyframes toast-in {
from { opacity: 0; transform: translateY(-6px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
.confirm-text { margin: 0; font-size: 0.875rem; white-space: pre-line; overflow-wrap: anywhere; }
/* Modals */ /* Modals */
dialog.modal { dialog.modal {
border: none; border-radius: 0.75rem; padding: 0; border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 0;
background: var(--c-surface); color: var(--c-text); background: var(--c-surface); color: var(--c-text);
width: min(34rem, calc(100vw - 2rem)); width: min(34rem, calc(100vw - 2rem));
box-shadow: 0 20px 60px rgba(0,0,0,0.35); box-shadow: 0 16px 48px rgba(19, 19, 22, 0.28);
} }
dialog.modal::backdrop { background: rgba(0,0,0,0.55); } dialog.modal::backdrop { background: rgba(19, 19, 22, 0.5); }
/* Пока открыта модалка — страница не прокручивается, позиция скролла сохраняется; /* Пока открыта модалка — страница не прокручивается, позиция скролла сохраняется;
прокрутка остаётся внутри .modal-body */ прокрутка остаётся внутри .modal-body */
html.modal-open body { html.modal-open body {
@@ -277,10 +339,13 @@ html.modal-open body {
.md-body h2 { font-size: 1.125rem; } .md-body h2 { font-size: 1.125rem; }
.md-body p, .md-body ul, .md-body ol { margin: 0.5rem 0; } .md-body p, .md-body ul, .md-body ol { margin: 0.5rem 0; }
.md-body ul, .md-body ol { padding-left: 1.5rem; } .md-body ul, .md-body ol { padding-left: 1.5rem; }
.md-body pre { background: var(--c-bg); border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.75rem 1rem; overflow-x: auto; font-size: 0.8125rem; } .md-body pre { background: var(--c-bg); border: 1px solid var(--c-border); border-radius: var(--radius); padding: 0.75rem 1rem; overflow-x: auto; font-size: 0.8125rem; }
.md-body code { font-family: monospace; } .md-body code { font-family: monospace; }
.md-body details.spoiler { border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.5rem 0.75rem; margin: 0.5rem 0; } /* Ссылки в учебных материалах — акцентные, с подчёркиванием: их видно в тексте */
.md-body details.spoiler summary { cursor: pointer; font-weight: 500; color: var(--c-text-link); } .md-body a { color: var(--c-accent); text-decoration: underline; text-underline-offset: 2px; }
.md-body a:hover { color: var(--c-accent-hover); }
.md-body details.spoiler { border: 1px solid var(--c-border); border-radius: var(--radius); padding: 0.5rem 0.75rem; margin: 0.5rem 0; }
.md-body details.spoiler summary { cursor: pointer; font-weight: 500; color: var(--c-accent); }
.md-body img { max-width: 100%; } .md-body img { max-width: 100%; }
.md-body table { margin: 0; } .md-body table { margin: 0; }
/* Таблицы в статьях прокручиваются по горизонтали, как блоки кода */ /* Таблицы в статьях прокручиваются по горизонтали, как блоки кода */
@@ -293,7 +358,7 @@ html.modal-open body {
font: inherit; font-size: 0.72rem; line-height: 1; font: inherit; font-size: 0.72rem; line-height: 1;
padding: 0.25rem 0.5rem; cursor: pointer; padding: 0.25rem 0.5rem; cursor: pointer;
color: var(--c-text-muted); background: var(--c-surface); color: var(--c-text-muted); background: var(--c-surface);
border: 1px solid var(--c-border); border-radius: 0.35rem; opacity: 0.85; border: 1px solid var(--c-border); border-radius: var(--radius-sm); opacity: 0.85;
} }
.md-copy:hover { color: var(--c-text); opacity: 1; } .md-copy:hover { color: var(--c-text); opacity: 1; }
.md-copy.copied { color: var(--c-text-link); } .md-copy.copied { color: var(--c-text-link); }
@@ -302,7 +367,7 @@ html.modal-open body {
.empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; } .empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; }
/* Mono inline code */ /* Mono inline code */
code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monospace; background: var(--c-bg); } code.code { padding: 0.125rem 0.5rem; border-radius: var(--radius-xs); font-family: monospace; background: var(--c-bg); }
/* Containers */ /* Containers */
.container-sm { max-width: 28rem; margin: 0 auto; } .container-sm { max-width: 28rem; margin: 0 auto; }
@@ -311,10 +376,10 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
/* Header logo */ /* Header logo */
.link-inherit { font-size: 0.875rem; }/* Chips (оценочные комментарии) */ .link-inherit { font-size: 0.875rem; }/* Chips (оценочные комментарии) */
.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: var(--radius-sm); font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; } .chip-pos { background: #2e6b3a; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; } .chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; } .chip-neg { background: #8f2d3c; color: #fff; }
/* Слоты комментариев в диалоге */ /* Слоты комментариев в диалоге */
.cs-row { display: flex; gap: 6px; align-items: center; } .cs-row { display: flex; gap: 6px; align-items: center; }
.cs-text-wrap { position: relative; flex: 1 1 auto; min-width: 0; } .cs-text-wrap { position: relative; flex: 1 1 auto; min-width: 0; }
@@ -325,7 +390,7 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
position: absolute; left: 0; right: 0; top: 100%; margin-top: 2px; z-index: 20; position: absolute; left: 0; right: 0; top: 100%; margin-top: 2px; z-index: 20;
max-height: 14rem; overflow-y: auto; max-height: 14rem; overflow-y: auto;
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: var(--radius-sm); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
} }
.ac-list.drop-up { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 2px; } .ac-list.drop-up { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 2px; }
.ac-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; padding-left: 0.45rem; border-left: 3px solid transparent; cursor: pointer; font-size: 0.875rem; } .ac-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; padding-left: 0.45rem; border-left: 3px solid transparent; cursor: pointer; font-size: 0.875rem; }
@@ -339,55 +404,87 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.ac-tone-neu { color: var(--c-tone-neu); } .ac-tone-neu { color: var(--c-tone-neu); }
.ac-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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-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 { flex: 0 0 auto; border: none; background: transparent; color: var(--c-text-muted); cursor: pointer; padding: 0 0.25rem; border-radius: var(--radius-xs); font-size: 0.75rem; line-height: 1.4; }
.ac-del:not(:disabled):hover { color: #dc2626; background: var(--c-bg); } .ac-del:not(:disabled):hover { color: #dc2626; background: var(--c-bg); }
.ac-del:disabled { opacity: 0.3; cursor: not-allowed; } .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); }
/* Ячейка матрицы: вариант и кнопки действий под ним; кнопки в шапке ЛР */ /* Ячейка матрицы в стиле Б3: вариант и оценка — текстом, подстрока — сдача и защита с точкой */
.cell-stack { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; } .cell-stack { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }
.cell-main { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; }
/* Публикация в ячейке — «глаз»-точка: ○ скрыто, ◉ опубликовано (нативный checkbox под капотом) */
.pub-toggle {
appearance: none; -webkit-appearance: none;
width: 1rem; height: 1rem; margin: 0; padding: 0; cursor: pointer;
border-radius: 50%; border: 1.5px solid var(--c-border-strong);
background: transparent; vertical-align: middle;
transition: border-color 0.15s, box-shadow 0.15s;
}
.pub-toggle:checked {
border-color: var(--c-accent);
background: radial-gradient(circle, var(--c-accent) 0 42%, transparent 48%);
}
.pub-toggle:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.variant-btn { .variant-btn {
display: inline-block; padding: 0.18rem 0.5rem; border-radius: 0.4rem; display: inline-block; padding: 0; border: none; background: none;
border: 1px solid var(--c-border); background: var(--c-bg); font-size: 0.84375rem; font-weight: 600; white-space: nowrap; cursor: pointer;
font-size: 0.8rem; white-space: nowrap; cursor: pointer; color: var(--c-text); color: var(--c-text); text-decoration: none;
} }
.variant-btn:hover { border-color: var(--c-accent); color: var(--c-accent); } .variant-btn:hover { color: var(--c-accent); text-decoration: underline; }
.pub-toggle { width: 1.05rem; height: 1.05rem; margin: 0; cursor: pointer; accent-color: #16a34a; } .variant-btn.manual { font-weight: 700; }
.variant-btn.manual { border-color: var(--c-border-strong, var(--c-border)); font-weight: 600; } .variant-btn .cell-none { color: var(--c-text-muted); font-weight: 400; }
.cell-btns { display: flex; gap: 0.2rem; } .variant-btn.manual .cell-none { font-weight: 600; }
.cell-btn { .score-link {
display: inline-flex; align-items: center; justify-content: center; gap: 0.15rem; display: inline-block; padding: 0; border: none; background: none;
min-width: 1.6rem; height: 1.6rem; padding: 0 0.35rem; cursor: pointer; font-size: 0.8125rem; font-weight: 600; font-variant-numeric: tabular-nums;
border: 1px solid var(--c-border); border-radius: 0.35rem; color: var(--c-text); cursor: pointer;
background: var(--c-surface); color: var(--c-text); text-decoration: none; font-size: 0.85rem;
} }
.cell-btn:hover { background: var(--c-bg); } .score-link:hover { color: var(--c-accent); text-decoration: underline; }
.cell-btn.dim { color: var(--c-text-muted); } .score-link.none { color: var(--c-text-muted); font-weight: 400; }
.cell-btn.warn { color: #d97706; } .cell-sub { display: flex; align-items: center; gap: 0.875rem; flex-wrap: wrap; font-size: 0.75rem; }
.cell-btn.ok { color: #16a34a; } .sub-link { color: var(--c-text-muted); text-decoration: none; font-variant-numeric: tabular-nums; }
.cell-num { font-size: 0.72rem; opacity: 0.75; } .sub-link:hover { color: var(--c-text); text-decoration: underline; }
.sub-dim { color: var(--c-text-muted); }
.cell-dim { color: var(--c-text-muted); }
/* Статус защиты: текст с цветной точкой вместо кнопки-плашки.
Класс cell-btn сохранён — по нему JS находит ячейку (defenseBtn). */
.cell-btn.st {
display: inline-flex; align-items: center; gap: 0.35rem;
min-width: 0; height: auto; padding: 0;
border: none; border-radius: 0; background: none;
color: var(--c-text-muted); font-size: 0.75rem; text-decoration: none; cursor: pointer;
}
.cell-btn.st:hover { background: none; color: var(--c-text); }
.cell-btn.st::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--c-border-strong); }
.cell-btn.st.ok { color: var(--c-tone-pos); }
.cell-btn.st.ok::before { background: var(--c-tone-pos); }
.cell-btn.st.warn { color: #b45309; }
.cell-btn.st.warn::before { background: #b45309; }
.cell-btn.st.dim { color: var(--c-text-muted); }
.cell-btn.st.dim::before { background: var(--c-border-strong); }
.mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); } .mini-count { font-size: 0.72rem; margin-left: 0.15rem; color: var(--c-text-muted); }
.defense-cell-timer { font-variant-numeric: tabular-nums; } .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;
min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer; min-width: 1.5rem; height: 1.5rem; padding: 0 0.3rem; cursor: pointer;
border: 1px solid var(--c-border); border-radius: 0.35rem; border: 1px solid var(--c-border); border-radius: var(--radius-sm);
background: var(--c-surface); color: var(--c-text); text-decoration: none; font-size: 0.85rem; background: var(--c-surface); color: var(--c-text); text-decoration: none; font-size: 0.85rem;
} }
.mini-btn:hover { background: var(--c-bg); } .mini-btn:hover { background: var(--c-bg); }
.mini-btn.ok { color: #16a34a; } .js-pub-eye .eye-closed { display: none; }
.mini-btn.warn { color: #d97706; } .js-pub-eye.is-empty .eye-open { display: none; }
.mini-btn.partial { color: #2563eb; } .js-pub-eye.is-empty .eye-closed { display: inline-block; }
.js-pub-count.is-hidden { display: none; }
/* Увеличенный диалог (оценка + комментарии) */ /* Увеличенный диалог (оценка + комментарии) */
dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); } dialog.modal.modal-lg { width: min(72rem, calc(100vw - 2rem)); }
dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; } dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.mini-btn svg, .cell-btn svg { width: 1rem; height: 1rem; } .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: var(--radius-sm); font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; } .chip-pos { background: #2e6b3a; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; } .chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; } .chip-neg { background: #8f2d3c; color: #fff; }
/* Defense timer */ /* Defense timer */
/* Материалы: ряды и ручки перетаскивания слева, одним столбцом */ /* Материалы: ряды и ручки перетаскивания слева, одним столбцом */
@@ -408,7 +505,7 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
} }
.sub-toggle:hover { color: var(--c-text); } .sub-toggle:hover { color: var(--c-text); }
.sub-arrow { .sub-arrow {
flex: 0 0 auto; width: 8px; height: 8px; display: block; flex: 0 0 auto; width: 8px; height: 8px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-135deg); transition: transform 0.2s; transform: rotate(-135deg); transition: transform 0.2s;
} }
@@ -416,20 +513,27 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.section-body { padding-bottom: 0.4rem; } .section-body { padding-bottom: 0.4rem; }
/* ---- Матрица группы и карточки защиты ---- */ /* ---- Матрица группы и карточки защиты ---- */
.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; } .scroll-x { overflow-x: auto; }
.matrix { border-collapse: separate; border-spacing: 0; } .matrix { border-collapse: separate; border-spacing: 0; }
.matrix th { vertical-align: top; } .matrix th {
.matrix td { vertical-align: middle; } vertical-align: top; padding: 0.75rem 1rem; text-align: left;
font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted);
background: var(--c-surface); white-space: nowrap;
}
.matrix td { vertical-align: top; padding: 1rem; }
.matrix tbody tr:hover td { background: rgba(19, 19, 22, 0.03); }
[data-theme="dark"] .matrix tbody tr:hover td { background: rgba(232, 233, 236, 0.045); }
.matrix tbody tr:last-child td { border-bottom: none; }
.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; }
.lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; } .student-name { font-weight: 600; font-size: 0.84375rem; }
.student-cell .font-mono { margin-top: 0.125rem; }
.lab-col-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.375rem; }
.lab-col-title {
max-width: 9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 0.7rem; font-weight: 400; color: var(--c-text-muted); letter-spacing: 0;
}
.cell-line { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; text-align: left; } .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-label { color: var(--c-text-muted); min-width: 3.5rem; }
.cell-link { font-size: 0.8125rem; } .cell-link { font-size: 0.8125rem; }
@@ -455,7 +559,7 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.pre-wrap { white-space: pre-wrap; word-break: break-word; } .pre-wrap { white-space: pre-wrap; word-break: break-word; }
/* Редактируемая матрица: закреплённый столбец и компактные селекты */ /* Редактируемая матрица: закреплённый столбец и компактные селекты */
.matrix-sticky { position: sticky; left: 0; z-index: 2; background: var(--c-surface); } .matrix-sticky { position: sticky; left: 0; z-index: 2; background: var(--c-surface); box-shadow: 8px 0 8px -8px rgba(19, 19, 22, 0.1); }
.matrix thead .matrix-sticky { z-index: 3; } .matrix thead .matrix-sticky { z-index: 3; }
.level-form { margin: 0; } .level-form { margin: 0; }
.level-select { .level-select {
@@ -474,7 +578,7 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.cell-chip { .cell-chip {
display: inline-flex; align-items: center; gap: 0.15rem; display: inline-flex; align-items: center; gap: 0.15rem;
font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem; font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem;
border: 1px solid var(--c-border); border-radius: 99px; border: 1px solid var(--c-border); border-radius: var(--radius-sm);
background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer; background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer;
text-decoration: none; font-family: inherit; text-decoration: none; font-family: inherit;
} }
@@ -482,12 +586,22 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.chip-dim { color: var(--c-text-muted); } .chip-dim { color: var(--c-text-muted); }
.chip-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; } .chip-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; }
.chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; } .chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; }
.chip-link:hover { color: var(--c-text-link); } .chip-link:hover { color: var(--c-accent); }
[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-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); } [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; } .lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; }
.actions-cell { white-space: nowrap; } .actions-cell { white-space: nowrap; }
.actions-cell .inline-form { display: inline-block; } .actions-cell .inline-form { display: inline-block; }
/* Действия столбиком: ровные одинаковые кнопки друг под другом */
.actions-col { display: inline-flex; flex-direction: column; gap: 0.35rem; align-items: stretch; min-width: 6.5rem; }
.actions-col .inline-form { display: block; width: 100%; }
.actions-col .btn { display: block; width: 100%; text-align: center; }
/* Монохромные SVG-иконки в ячейках матрицы (защита, оценка) */
.cell-ico {
width: 0.95rem; height: 0.95rem; display: block;
fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
}
/* tablekit: сортируемые таблицы и поиск */ /* tablekit: сортируемые таблицы и поиск */
th.th-sortable { cursor: pointer; user-select: none; } th.th-sortable { cursor: pointer; user-select: none; }
@@ -500,7 +614,7 @@ th.th-sortable.is-desc::after { content: " ▼"; color: var(--c-text-muted); fon
.filter-row th { padding: 0.25rem 0.35rem; background: var(--c-bg); } .filter-row th { padding: 0.25rem 0.35rem; background: var(--c-bg); }
.filter-control { .filter-control {
width: 100%; min-width: 3.5rem; font-size: 0.72rem; line-height: 1.4; width: 100%; min-width: 3.5rem; font-size: 0.72rem; line-height: 1.4;
padding: 0.1rem 0.25rem; border: 1px solid var(--c-border); border-radius: 0.35rem; padding: 0.1rem 0.25rem; border: 1px solid var(--c-border); border-radius: var(--radius-sm);
background: var(--c-surface); color: var(--c-text); font-family: inherit; background: var(--c-surface); color: var(--c-text); font-family: inherit;
} }
th.th-filterable { padding-bottom: 0.35rem; } th.th-filterable { padding-bottom: 0.35rem; }
@@ -509,14 +623,16 @@ th.th-filterable { padding-bottom: 0.35rem; }
.drag-handle { .drag-handle {
display: inline-block; flex: 0 0 auto; width: 1.5rem; text-align: center; display: inline-block; flex: 0 0 auto; width: 1.5rem; text-align: center;
cursor: grab; user-select: none; color: var(--c-text-muted); cursor: grab; user-select: none; color: var(--c-text-muted);
font-size: 1rem; line-height: 1; padding: 0.15rem 0; border-radius: 0.35rem; font-size: 1rem; line-height: 1; padding: 0.15rem 0; border-radius: var(--radius-sm);
} }
.drag-handle svg { width: 1rem; height: 1rem; display: block; margin: 0 auto; }
.drag-handle:hover { background: var(--c-bg); color: var(--c-text); } .drag-handle:hover { background: var(--c-bg); color: var(--c-text); }
.mini-btn.danger:hover { color: var(--c-accent); border-color: #dcb9be; background: var(--c-accent-soft); }
.drag-handle:active { cursor: grabbing; } .drag-handle:active { cursor: grabbing; }
.dragging { opacity: 0.45; } .dragging { opacity: 0.45; }
.drop-before { box-shadow: 0 -3px 0 0 var(--c-text); } .drop-before { box-shadow: 0 -3px 0 0 var(--c-text); }
.drop-after { box-shadow: 0 3px 0 0 var(--c-text); } .drop-after { box-shadow: 0 3px 0 0 var(--c-text); }
.drop-into { outline: 2px dashed var(--c-border); outline-offset: 3px; border-radius: 0.35rem; } .drop-into { outline: 2px dashed var(--c-border); outline-offset: 3px; border-radius: var(--radius-sm); }
/* индикатор сохранения порядка материалов */ /* индикатор сохранения порядка материалов */
.sort-status { .sort-status {
@@ -524,7 +640,7 @@ th.th-filterable { padding-bottom: 0.35rem; }
display: flex; align-items: center; gap: 0.6rem; display: flex; align-items: center; gap: 0.6rem;
max-width: min(24rem, calc(100vw - 2rem)); max-width: min(24rem, calc(100vw - 2rem));
background: var(--c-surface); border: 1px solid var(--c-border); background: var(--c-surface); border: 1px solid var(--c-border);
border-radius: 0.5rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); padding: 0.5rem 0.75rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem;
} }
@@ -555,13 +671,13 @@ th.th-filterable { padding-bottom: 0.35rem; }
.md-toolbar { .md-toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem;
padding: 0.3rem; margin-bottom: 0.5rem; padding: 0.3rem; margin-bottom: 0.5rem;
border: 1px solid var(--c-border); border-radius: 0.5rem; background: var(--c-bg); border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-bg);
} }
.md-sprite { display: none; } .md-sprite { display: none; }
.md-btn { .md-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 2rem; height: 2rem; padding: 0; cursor: pointer; width: 2rem; height: 2rem; padding: 0; cursor: pointer;
border: 1px solid transparent; border-radius: 0.375rem; border: 1px solid transparent; border-radius: var(--radius-sm);
background: transparent; color: var(--c-text); background: transparent; color: var(--c-text);
} }
.md-btn:hover { background: var(--c-surface); border-color: var(--c-border); } .md-btn:hover { background: var(--c-surface); border-color: var(--c-border); }
@@ -587,4 +703,4 @@ th.th-filterable { padding-bottom: 0.35rem; }
margin-top: 0.4rem; font-size: 0.875rem; color: var(--c-text-muted); margin-top: 0.4rem; font-size: 0.875rem; color: var(--c-text-muted);
} }
.md-video-figure { margin: 1rem 0; text-align: center; } .md-video-figure { margin: 1rem 0; text-align: center; }
.md-video { width: 100%; max-width: 100%; border-radius: 0.5rem; background: #000; } .md-video { width: 100%; max-width: 100%; border-radius: var(--radius); background: #000; }

View File

@@ -1,7 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#6366f1"/> <rect x="1" y="1" width="30" height="30" rx="8" fill="#7a2333"/>
<path d="M9 8v13h5l-1.5 3H6V8z" fill="#eef2ff"/> <path d="M11.5 7.5h4v13.2h8.2v3.8h-12.2z" fill="#fdfcf9"/>
<path d="M23 8v13h-5l1.5 3h5V8z" fill="#c7d2fe"/>
<path d="M11 9c2-1.5 4-1.5 5 0 1-1.5 3-1.5 5 0v10c-2-1.5-4-1.5-5 0-1-1.5-3-1.5-5 0z" fill="#ffffff"/>
<path d="M16 9v10" stroke="#6366f1" stroke-width="1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -57,7 +57,7 @@ function csRow(text, tone) {
var rm = document.createElement('button'); var rm = document.createElement('button');
rm.type = 'button'; rm.type = 'button';
rm.className = 'btn btn-secondary btn-sm'; rm.className = 'btn btn-secondary';
rm.textContent = 'удалить'; rm.textContent = 'удалить';
rm.onclick = function () { row.remove(); }; rm.onclick = function () { row.remove(); };
@@ -260,11 +260,12 @@ function attachPublishToggles() {
if (!urlEl) return; if (!urlEl) return;
var csrfEl = document.querySelector('input[name="_csrf"]'); var csrfEl = document.querySelector('input[name="_csrf"]');
document.querySelectorAll('.pub-toggle').forEach(function (cb) { document.querySelectorAll('.pub-toggle').forEach(function (cb) {
var labId = cb.getAttribute('data-lab') || '';
cb.addEventListener('change', function () { cb.addEventListener('change', function () {
var desired = cb.checked; var desired = cb.checked;
var body = new URLSearchParams(); var body = new URLSearchParams();
body.set('_csrf', csrfEl ? csrfEl.value : ''); body.set('_csrf', csrfEl ? csrfEl.value : '');
body.set('labId', cb.getAttribute('data-lab') || ''); body.set('labId', labId);
body.set('studentId', cb.getAttribute('data-student') || ''); body.set('studentId', cb.getAttribute('data-student') || '');
body.set('published', desired ? 'true' : 'false'); body.set('published', desired ? 'true' : 'false');
cb.disabled = true; cb.disabled = true;
@@ -277,9 +278,14 @@ function attachPublishToggles() {
.then(function (res) { .then(function (res) {
cb.disabled = false; cb.disabled = false;
if (res && res.ok) { if (res && res.ok) {
showToast(desired showToast((desired ? 'Опубликовано: ' : 'Скрыто: ') + res.count + ' из ' + res.total);
? 'Оценка и комментарии опубликованы студенту.' var eye = document.querySelector('.mini-btn.js-pub-eye[data-lab="' + labId + '"]');
: 'Оценка и комментарии скрыты от студента.'); if (eye) {
eye.setAttribute('data-count', String(res.count || 0));
eye.setAttribute('data-total', String(res.total || 0));
eye.setAttribute('data-all', res.all ? 'true' : 'false');
updateEyeCounts();
}
} else { } else {
cb.checked = !desired; cb.checked = !desired;
showToast((res && res.error) || 'Не удалось изменить публикацию.', 'error'); showToast((res && res.error) || 'Не удалось изменить публикацию.', 'error');
@@ -294,19 +300,166 @@ function attachPublishToggles() {
}); });
} }
/* Перерисовка «глазика» публикации по атрибутам data-count/data-total/data-all. */
function updateEyeCounts() {
document.querySelectorAll('.mini-btn.js-pub-eye').forEach(function (eye) {
var total = parseInt(eye.getAttribute('data-total') || '0', 10);
var count = parseInt(eye.getAttribute('data-count') || '0', 10);
var all = eye.getAttribute('data-all') === 'true';
eye.classList.toggle('is-empty', count === 0);
eye.classList.toggle('is-all', all);
eye.classList.toggle('is-partial', count > 0 && !all);
var c = eye.querySelector('.js-pub-count');
if (c) {
c.textContent = count;
c.classList.toggle('is-hidden', count === 0 || all);
}
eye.title = all
? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех'
: (count > 0
? 'Опубликовано ' + count + ' из ' + total + ' — нажмите, чтобы опубликовать всем'
: 'Показать оценки и комментарии всей группе');
var form = eye.closest('form');
var hidden = form ? form.querySelector('.js-pub-published') : null;
if (hidden) hidden.value = all ? 'false' : 'true';
});
}
/* ---------- За Protection: синхронизация таймеров со статусами с сервером (10 с) ---------- */
var DEFENSE_STATE = {};
var DEFENSE_POLL_MS = 10000;
var DEFENSE_TICK_MS = 1000;
function defenseBtn(sessionId) {
if (!sessionId) return null;
return document.querySelector('.cell-btn.js-defense-cell[data-session="' + sessionId + '"]');
}
function applyDefenseToCell(s) {
var btn = defenseBtn(s.sessionId);
if (!btn) return;
btn.setAttribute('data-status', s.status || '');
if (s.status === 'started' && s.endsAt) btn.setAttribute('data-seconds', String(Math.max(0, Math.ceil((s.endsAt - Date.now()) / 1000))));
if (s.code) btn.setAttribute('data-code', s.code); else btn.removeAttribute('data-code');
btn.classList.remove('ok', 'warn', 'dim');
btn.classList.add(s.status === 'ready' ? 'ok' : (s.status === 'waiting' || s.status === 'started' ? 'warn' : 'dim'));
var label = btn.querySelector('.js-def-label');
var timer = btn.querySelector('.js-def-timer');
var started = s.status === 'started';
if (!timer && started) {
timer = document.createElement('span');
timer.className = 'defense-cell-timer js-def-timer';
btn.appendChild(timer);
}
if (timer) {
timer.hidden = !started;
if (started) timer.textContent = fmtSeconds(Math.max(0, Math.ceil((s.endsAt - Date.now()) / 1000)));
}
if (label) {
label.textContent = started ? ''
: (s.status == null ? '—'
: (s.status === 'ready' ? 'готова'
: (s.code ? 'код ' + s.code : 'ожидает')));
}
if (s.status === 'ready') {
btn.title = 'Защита: ' + defenseReasonText(s.readyReason);
if (label) label.textContent = 'готова';
}
}
function syncDefenseStatuses(sessions, initial) {
(sessions || []).forEach(function (s) {
var prev = DEFENSE_STATE[s.sessionId];
var st = {
sessionId: s.sessionId,
status: s.status,
code: s.code,
secondsLeft: s.secondsLeft,
readyReason: s.readyReason,
endsAt: s.status === 'started' ? Date.now() + Math.max(0, parseInt(s.secondsLeft || 0, 10) * 1000) : null
};
DEFENSE_STATE[s.sessionId] = st;
applyDefenseToCell(st);
// Уведомление о переходе в «готов» (не при первой загрузке).
if (!initial && prev && prev.status === 'started' && s.status === 'ready') {
var btn = defenseBtn(s.sessionId);
var who = (btn && btn.getAttribute('data-student-name')) || 'студент';
var lab = (btn && btn.getAttribute('data-lab-name')) || 'лабораторная';
var reason = s.readyReason === 'timeout' ? 'время вышло' : 'нажал «Готов»';
showToast(who + ' — ' + lab + ': ' + reason + '.');
}
// Обновляем открытый диалог, если он про эту сессию.
var dlg = document.getElementById('modal-defense');
if (dlg && dlg.open && DEFENSE_OPEN_SESSION === s.sessionId) {
var statusEl = $id('defense-status');
if (statusEl) {
statusEl.textContent = defenseStatusText(s.status, s.code,
s.status === 'started' ? Math.max(0, Math.ceil((st.endsAt - Date.now()) / 1000)) : 0,
s.readyReason);
}
var resetForm = $id('defense-reset-form');
if (resetForm) resetForm.hidden = s.status !== 'ready';
}
});
}
function startDefenseWatch() {
var meta = $id('defense-meta');
if (!meta) return;
var labs = meta.getAttribute('data-labs') || '';
if (!labs) return;
var url = '/admin/defense-watch?labs=' + encodeURIComponent(labs);
var first = true;
function poll() {
fetch(url)
.then(function (r) { return r.ok ? r.json() : { ok: false }; })
.then(function (res) {
if (res && res.ok) {
syncDefenseStatuses(res.sessions, first);
first = false;
}
})
.catch(function () {});
}
poll();
setInterval(poll, DEFENSE_POLL_MS);
setInterval(function () {
Object.keys(DEFENSE_STATE).forEach(function (id) {
var s = DEFENSE_STATE[id];
if (s.status === 'started') applyDefenseToCell(s);
});
}, DEFENSE_TICK_MS);
}
/* ---------- Индивидуальное задание (защита) ---------- */ /* ---------- Индивидуальное задание (защита) ---------- */
function defenseStatusText(status, code, seconds) {
function defenseReasonText(reason) {
if (reason === 'timeout') return 'Вышло время';
return 'Готов к сдаче';
}
function defenseStatusText(status, code, seconds, reason) {
if (status === 'waiting') { if (status === 'waiting') {
return code return code
? 'Задание назначено, код выдан — ждём входа студента.' ? 'Задание назначено. Код входа: ' + code + ' — ждём входа студента.'
: 'Задание назначено, код ещё не выдан.'; : 'Задание назначено, код ещё не выдан.';
} }
if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.'; if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.';
if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.'; if (status === 'ready') {
return defenseReasonText(reason) + '. Можно начать заново для дополнительного задания.';
}
return ''; return '';
} }
/** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */ /** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
var DEFENSE_OPEN_SESSION = '';
function openDefense(btn) { function openDefense(btn) {
var form = $id('defense-form'); var form = $id('defense-form');
if (!form) return; if (!form) return;
@@ -321,15 +474,17 @@ function openDefense(btn) {
var status = btn.getAttribute('data-status') || ''; var status = btn.getAttribute('data-status') || '';
var code = btn.getAttribute('data-code') || ''; var code = btn.getAttribute('data-code') || '';
var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10); var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10);
var reason = btn.getAttribute('data-reason') || '';
var actions = $id('defense-actions'); var actions = $id('defense-actions');
var statusEl = $id('defense-status'); var statusEl = $id('defense-status');
DEFENSE_OPEN_SESSION = session;
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';
$id('defense-action-lab').value = lab; $id('defense-action-lab').value = 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 (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds); if (statusEl) statusEl.textContent = defenseStatusText(status, code, seconds, reason);
actions.hidden = false; actions.hidden = false;
} else { } else {
if (statusEl) statusEl.textContent = ''; if (statusEl) statusEl.textContent = '';
@@ -361,7 +516,14 @@ function openVariant(btn) {
levelSel.innerHTML = ''; levelSel.innerHTML = '';
VARIANT_CACHE = {}; VARIANT_CACHE = {};
fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema') fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema')
.then(function (r) { return r.ok ? r.json() : { levels: [] }; }) .then(function (r) { return r.ok && !r.redirected ? r.json() : { levels: [] }; })
.catch(function () {
window.showAlert({
title: 'Не удалось открыть',
text: 'Не удалось загрузить уровни и варианты (возможно, сессия истекла). Обновите страницу и откройте модалку заново.'
});
return { levels: [] };
})
.then(function (data) { .then(function (data) {
(data.levels || []).forEach(function (lvl, i) { (data.levels || []).forEach(function (lvl, i) {
var opt = document.createElement('option'); var opt = document.createElement('option');
@@ -427,20 +589,6 @@ function showVariantTask() {
} }
} }
/* ---------- Таймеры защиты в ячейках ---------- */
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() {
@@ -448,7 +596,7 @@ function focusLab() {
if (!s) return; if (!s) return;
var labId = s.value; var labId = s.value;
document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab], .filter-row th[data-lab]').forEach(function (el) { document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab], .filter-row th[data-lab]').forEach(function (el) {
el.style.display = (!labId || el.getAttribute('data-lab') === labId) ? '' : 'none'; el.toggleAttribute('hidden', !!labId && el.getAttribute('data-lab') !== labId);
}); });
} }
@@ -458,6 +606,65 @@ document.addEventListener('submit', function (ev) {
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
focusLab(); focusLab();
tickDefenseCells(); startDefenseWatch();
attachPublishToggles(); attachPublishToggles();
}); });
/* ---------- Большая модалка «Выдача вариантов» ---------- */
function refreshAssignModal(url) {
var body = document.getElementById('assign-modal-body');
if (!body) return Promise.resolve();
return fetch(url, { headers: { 'X-Requested-With': 'fetch' } })
.then(function (r) {
if (r.redirected) throw new Error('сессия истекла — обновите страницу');
return r.text();
})
.then(function (html) { body.innerHTML = html; });
}
window.openLabAssign = function (btn) {
var body = document.getElementById('assign-modal-body');
if (!body) return;
var title = document.getElementById('assign-modal-title');
if (title) title.textContent = 'Выдача вариантов — ' + (btn.getAttribute('data-lab-name') || '');
body.innerHTML = '<p class="text-muted">Загрузка…</p>';
openModal('modal-lab-assign');
refreshAssignModal(btn.getAttribute('data-url')).catch(function (e) {
body.innerHTML = '<p class="text-danger">Не удалось загрузить: ' + e.message + '</p>';
});
};
window.confirmAssignVariant = function (sel) {
var opt = sel.selectedOptions[0];
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
var submit = function () {
sel.dataset.prev = sel.value;
sel.closest('form').requestSubmit();
};
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
window.showConfirm({
text: 'Вариант уже занят (' + taken + '). Назначить всё равно?',
okText: 'Назначить'
}).then(function (ok) {
if (ok) submit();
else sel.value = sel.dataset.prev;
});
return;
}
submit();
};
/* Формы внутри модалки сохраняются через fetch — модалка не закрывается и обновляется */
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.classList || !form.classList.contains('js-assign-form')) return;
ev.preventDefault();
var btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-Requested-With': 'fetch' } })
.then(function (r) { if (r.redirected) throw new Error('сессия истекла — обновите страницу'); return r.text(); })
.then(function () { return refreshAssignModal(form.getAttribute('data-refresh')); })
.catch(function (e) { showToast('Не удалось сохранить: ' + e.message, 'error'); })
.finally(function () { if (btn) btn.disabled = false; });
});

View File

@@ -1,36 +1,22 @@
function toggleAllStudents(cb) { /* Страница лабораторной: переключение просмотра/редактирования параметров. */
document.querySelectorAll('.pick-student').forEach(function (c) { c.checked = cb.checked; }); (function () {
} function setMode(editing) {
var view = document.getElementById('lab-description-view');
function collectPicked(form) { var form = document.getElementById('lab-edit-form');
var picked = Array.prototype.slice.call(document.querySelectorAll('.pick-student:checked')); if (!view || !form) return;
if (!picked.length) { view.hidden = editing;
alert('Отметьте хотя бы одного студента.'); form.hidden = !editing;
return false; if (editing) {
} var t = form.querySelector('input[name="title"]');
picked.forEach(function (c) { if (t) t.focus();
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.addEventListener('click', function (e) {
document.querySelectorAll('.pick-variant').forEach(function (s) { s.dataset.prev = s.value; }); var a = e.target.closest('[data-action]');
}); if (!a) return;
var kind = a.getAttribute('data-action');
if (kind === 'lab-edit') setMode(true);
if (kind === 'lab-edit-cancel') setMode(false);
});
})();

View File

@@ -0,0 +1,254 @@
/* Общая логика интерфейса: тема, подсветка кода, модалки, подтверждения,
уведомления, скролл-лок при открытых диалогах, активный пункт меню.
Никаких инлайновых обработчиков в шаблонах — только классы и data-атрибуты. */
(function () {
'use strict';
/* Тема */
window.toggleTheme = function () {
var root = document.documentElement;
var t = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', t);
try { localStorage.setItem('lms-theme', t); } catch (e) { /* приватный режим */ }
window.__hljsSet(t);
};
/* Подсветка кода */
window.__hljsSet = function (theme) {
var l = document.getElementById('hljs-light'), d = document.getElementById('hljs-dark');
if (l && d) { l.disabled = theme !== 'light'; d.disabled = theme !== 'dark'; }
};
window.__hljsAll = function () {
if (!window.hljs) return;
document.querySelectorAll('pre code').forEach(function (el) { window.hljs.highlightElement(el); });
};
/* Модалки */
window.openModal = function (id) {
var d = document.getElementById(id);
if (d) d.showModal();
syncModalScrollLock();
};
window.closeModal = function (id) {
var d = document.getElementById(id);
if (d) d.close();
syncModalScrollLock();
};
/* Скролл-лок: пока открыт диалог, страница остаётся на месте */
var modalScrollY = 0;
function syncModalScrollLock() {
var root = document.documentElement;
if (document.querySelector('dialog[open]')) {
if (root.classList.contains('modal-open')) return;
modalScrollY = window.scrollY || window.pageYOffset || 0;
var sw = window.innerWidth - root.clientWidth;
root.style.setProperty('--modal-scrollbar', (sw > 0 ? sw : 0) + 'px');
root.style.setProperty('--modal-top', (-modalScrollY) + 'px');
root.classList.add('modal-open');
} else if (root.classList.contains('modal-open')) {
root.classList.remove('modal-open');
root.style.removeProperty('--modal-scrollbar');
root.style.removeProperty('--modal-top');
window.scrollTo(0, modalScrollY);
}
}
document.addEventListener('toggle', function (e) {
if (e.target && e.target.tagName === 'DIALOG') syncModalScrollLock();
}, true);
/* Секции материалов: раскрытие и запоминание состояния */
function sectionStateKey(id) { return 'lms-section-open:' + id; }
function applySectionState(acc) {
var id = acc.getAttribute('data-section-id');
var open = sessionStorage.getItem(sectionStateKey(id)) === '1';
var def = acc.getAttribute('data-default-open') === 'true';
acc.classList.toggle('collapsed', !open && !def);
var btn = acc.querySelector('.sub-toggle');
if (btn) btn.setAttribute('aria-expanded', String(!acc.classList.contains('collapsed')));
}
function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc');
if (!acc) return;
acc.classList.toggle('collapsed');
var collapsed = acc.classList.contains('collapsed');
btn.setAttribute('aria-expanded', String(!collapsed));
try {
sessionStorage.setItem(sectionStateKey(acc.getAttribute('data-section-id')), collapsed ? '0' : '1');
} catch (e) { /* приватный режим */ }
};
/* Активный пункт меню */
function initNavActive() {
var path = window.location.pathname;
var links = [].slice.call(document.querySelectorAll('#top-nav .nav-link'));
var best = null;
var bestLen = -1;
links.forEach(function (a) {
var href = a.getAttribute('href') || '';
if (href === '/') return;
if (path === href || path.indexOf(href + '/') === 0) {
if (href.length > bestLen) { best = a; bestLen = href.length; }
}
});
if (best) best.classList.add('active');
}
/* Серверные уведомления: автоскрытие и кнопка закрытия */
function initDataToasts() {
document.querySelectorAll('[data-toast]').forEach(function (toast) {
var timer = setTimeout(function () { toast.remove(); }, 30000);
var close = toast.querySelector('.toast-close');
if (close) close.addEventListener('click', function () { clearTimeout(timer); toast.remove(); });
});
}
document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll();
initNavActive();
initSections();
initDataToasts();
syncModalScrollLock();
});
/* ---- Делегирование: вместо onclick/onsubmit/onchange в разметке ---- */
/* Диалог подтверждения/уведомления — замена нативным alert и confirm */
var confirmResolve = null;
function confirmFinish(result) {
var r = confirmResolve;
confirmResolve = null;
if (r) r(result);
}
(function () {
var d = document.getElementById('modal-confirm');
if (!d) return;
var ok = d.querySelector('#confirm-ok');
var cancel = d.querySelector('#confirm-cancel');
ok.addEventListener('click', function () { confirmFinish(true); d.close(); });
cancel.addEventListener('click', function () { confirmFinish(false); d.close(); });
d.addEventListener('close', function () { confirmFinish(false); });
})();
window.showConfirm = function (opts) {
opts = opts || {};
var d = document.getElementById('modal-confirm');
if (!d) return Promise.resolve(true);
return new Promise(function (resolve) {
document.getElementById('confirm-title').textContent = opts.title || 'Подтверждение';
document.getElementById('confirm-text').textContent = opts.text || '';
var ok = document.getElementById('confirm-ok');
var cancel = document.getElementById('confirm-cancel');
ok.textContent = opts.okText || 'ОК';
ok.classList.toggle('btn-danger', !!opts.danger);
ok.classList.toggle('btn-primary', !opts.danger);
cancel.textContent = opts.cancelText || 'Отмена';
cancel.hidden = !!opts.hideCancel;
confirmResolve = resolve;
window.openModal('modal-confirm');
});
};
window.showAlert = function (opts) {
opts = opts || {};
return window.showConfirm({
title: opts.title || 'Внимание',
text: opts.text || '',
okText: 'Понятно',
hideCancel: true
});
};
/* Подтверждения: data-confirm на кнопке — спрашиваем при клике,
на форме — при сабмите (Enter или кнопка без data-confirm). */
document.addEventListener('click', function (e) {
var el = e.target.closest('[data-confirm]');
if (!el || el.tagName === 'FORM') return;
e.preventDefault();
e.stopImmediatePropagation();
var danger = el.classList.contains('btn-danger') || el.classList.contains('danger');
window.showConfirm({
text: el.getAttribute('data-confirm'),
danger: danger,
okText: danger ? 'Удалить' : 'Продолжить'
}).then(function (ok) {
if (!ok) return;
if (el.tagName === 'BUTTON' && el.form) el.form.requestSubmit(el);
});
}, true);
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f.matches('form[data-confirm]') || f.dataset.confirmed) return;
e.preventDefault();
var danger = f.classList.contains('btn-danger');
window.showConfirm({
text: f.getAttribute('data-confirm'),
danger: danger,
okText: danger ? 'Удалить' : 'Продолжить'
}).then(function (ok) {
if (!ok) return;
f.dataset.confirmed = '1';
f.requestSubmit();
});
}, true);
/* Клик-действия */
document.addEventListener('click', function (e) {
var el = e.target;
var close = el.closest('.modal-close');
if (close) {
var dlg = close.closest('dialog');
if (dlg) dlg.close();
syncModalScrollLock();
return;
}
var toast = el.closest('.toast-close');
if (toast) {
var item = toast.closest('.toast-item');
if (item) item.remove();
return;
}
var themeBtn = el.closest('.theme-btn');
if (themeBtn) { window.toggleTheme(); return; }
var toggle = el.closest('.sub-toggle');
if (toggle) { window.toggleSection(toggle); return; }
var opener = el.closest('[data-modal]');
if (opener) { window.openModal(opener.getAttribute('data-modal')); return; }
var action = el.closest('[data-action]');
if (!action) return;
var kind = action.getAttribute('data-action');
var fn = {
grade: window.openGradeEditor,
variant: window.openVariant,
defense: window.openDefense,
assign: window.openLabAssign,
'article-edit': window.startArticleEdit,
'article-edit-cancel': window.cancelArticleEdit
}[kind];
if (typeof fn === 'function') fn(action);
else if (kind === 'comment-add' && typeof window.csRow === 'function') window.csRow('', 'neutral');
});
/* Изменение полей: автоотправка форм (data-auto-submit),
подтверждение занятия занятого варианта (select.js-variant-select) */
document.addEventListener('change', function (e) {
var t = e.target;
if (t.matches('.js-variant-select') && typeof window.confirmAssignVariant === 'function') {
window.confirmAssignVariant(t);
return;
}
if (t.matches('[data-auto-submit]')) {
var f = t.closest('form');
if (f) f.requestSubmit();
}
});
})();

View File

@@ -239,6 +239,9 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()) body: JSON.stringify(collectPayload())
}).then(function (response) { }).then(function (response) {
// после истечения сессии сервер отвечает редиректом на /login: fetch вернёт 200 с HTML —
// считать такое успехом нельзя, иначе порядок «сохранится» только на экране
if (response.redirected) throw new Error('сессия истекла — обновите страницу');
if (!response.ok) throw new Error('HTTP ' + response.status); if (!response.ok) throw new Error('HTTP ' + response.status);
pending = false; pending = false;
saving = false; saving = false;

View File

@@ -1,15 +1,67 @@
/* Таймер защиты: локальный отщёлник + подстройка по серверу каждые ~10 секунд. */
document.querySelectorAll('.defense-timer').forEach(function (el) { document.querySelectorAll('.defense-timer').forEach(function (el) {
var labId = el.getAttribute('data-lab') || '';
var left = parseInt(el.getAttribute('data-seconds') || '0', 10); var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = null;
var ended = false;
function render(v) { function render(v) {
var m = Math.floor(v / 60), s = v % 60; var m = Math.floor(v / 60), s = v % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s; el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
} }
render(left);
var tick = setInterval(function () { function startTick() {
left -= 1; if (tick) clearInterval(tick);
if (left <= 0) { clearInterval(tick); window.location.reload(); return; } tick = setInterval(function () {
left -= 1;
if (left <= 0) { finish(); return; }
render(left);
}, 1000);
}
function finish() {
if (ended) return;
ended = true;
if (tick) clearInterval(tick);
window.location.reload();
}
if (left > 0) {
render(left); render(left);
}, 1000); startTick();
markDefense(true);
}
function markDefense(active) {
var name = document.querySelector('.nav-user');
if (name) name.classList.toggle('defense-active', active);
}
if (labId) {
setInterval(function () {
fetch('/student/labs/' + encodeURIComponent(labId) + '/defense-status')
.then(function (r) { return r.ok ? r.json() : { ok: false }; })
.then(function (res) {
if (!res || !res.ok) return;
// У сессии закончилось время.
if (res.status !== 'started') {
markDefense(false);
finish();
return;
}
markDefense(true);
var server = parseInt(res.secondsLeft || '0', 10);
if (server <= 0) { finish(); return; }
// Подстраиваем локальный отсчёт под серверное время.
if (Math.abs(server - left) > 2) {
left = server;
startTick();
}
render(left);
})
.catch(function () {});
}, 10000);
}
}); });
document.querySelectorAll('.ru-datetime').forEach(function (el) { document.querySelectorAll('.ru-datetime').forEach(function (el) {
try { try {

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,7 @@
var area = document.createElement('textarea'); var area = document.createElement('textarea');
area.value = text; area.value = text;
area.setAttribute('readonly', ''); area.setAttribute('readonly', '');
area.style.position = 'fixed'; area.className = 'offscreen';
area.style.top = '-1000px';
document.body.appendChild(area); document.body.appendChild(area);
area.select(); area.select();
try { document.execCommand('copy'); } catch (e) { /* игнорируем */ } try { document.execCommand('copy'); } catch (e) { /* игнорируем */ }

View File

@@ -103,17 +103,25 @@
}; };
window.cancelArticleEdit = function () { window.cancelArticleEdit = function () {
if (dirty && !confirm('Выйти из редактирования без сохранения?')) return; var proceed = function () {
var view = document.getElementById('article-view'); var view = document.getElementById('article-view');
var form = document.getElementById('article-edit'); var form = document.getElementById('article-edit');
if (!view || !form) return; if (!view || !form) return;
form.hidden = true; form.hidden = true;
view.hidden = false; view.hidden = false;
dirty = false; dirty = false;
};
if (!dirty) { proceed(); return; }
window.showConfirm({
title: 'Несохранённые изменения',
text: 'Выйти из редактирования без сохранения?',
okText: 'Выйти'
}).then(function (ok) { if (ok) proceed(); });
}; };
document.addEventListener('input', function (event) { document.addEventListener('input', function (event) {
if (event.target && event.target.id === 'article-body') dirty = true; var id = event.target && event.target.id;
if (id === 'article-body' || id === 'article-title') dirty = true;
}); });
/* Сохранение — это осознанный уход со страницы, подтверждение не нужно */ /* Сохранение — это осознанный уход со страницы, подтверждение не нужно */

View File

@@ -0,0 +1,32 @@
/* Защита от двойной отправки: после сабмита формы кнопки блокируются до перехода.
Инлайн-onsubmit с confirm() успевает отработать раньше (обработчик из атрибута
регистрируется первым) — если сабмит отменён, кнопки не блокируются. */
(function () {
function buttonsOf(form) {
return form.querySelectorAll('button[type="submit"]');
}
function setDisabled(form, disabled) {
buttonsOf(form).forEach(function (btn) { btn.disabled = disabled; });
}
function arm(form) {
if (form.dataset.submitGuard) return;
form.dataset.submitGuard = '1';
form.addEventListener('submit', function (event) {
if (event.defaultPrevented) return;
window.setTimeout(function () {
// кто-то из более поздних обработчиков мог отменить сабмит
if (!event.defaultPrevented) setDisabled(form, true);
}, 0);
});
// возврат на страницу по Back/Forward из кэша — разблокируем
window.addEventListener('pageshow', function (event) {
if (event.persisted) setDisabled(form, false);
});
}
window.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form').forEach(arm);
});
})();

View File

@@ -158,13 +158,26 @@
if (th.hasAttribute('data-nosort')) return; if (th.hasAttribute('data-nosort')) return;
th.classList.add('th-sortable'); th.classList.add('th-sortable');
if (!th.title) th.title = 'Сортировать по этому столбцу'; if (!th.title) th.title = 'Сортировать по этому столбцу';
th.addEventListener('click', function (event) { // сортировка с клавиатуры: фокус + Enter/пробел, состояние доступно скринридеру
if (event.target.closest('form, button, a, select, input')) return; th.tabIndex = 0;
if (th.classList.contains('is-asc')) th.setAttribute('aria-sort', 'ascending');
function doSort() {
var asc = !th.classList.contains('is-asc'); var asc = !th.classList.contains('is-asc');
clearAllMarkers(table); clearAllMarkers(table);
th.classList.add(asc ? 'is-asc' : 'is-desc'); th.classList.add(asc ? 'is-asc' : 'is-desc');
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
sortColumn(table, index, asc ? 1 : -1); sortColumn(table, index, asc ? 1 : -1);
th.title = (th.textContent.trim() || 'Столбец') + ' — ' + (asc ? 'по возрастанию' : 'по убыванию'); th.title = (th.textContent.trim() || 'Столбец') + ' — ' + (asc ? 'по возрастанию' : 'по убыванию');
}
th.addEventListener('click', function (event) {
if (event.target.closest('form, button, a, select, input')) return;
doSort();
});
th.addEventListener('keydown', function (event) {
if (event.target !== th) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
doSort();
}); });
}); });
buildFilters(table); buildFilters(table);

View File

@@ -0,0 +1,6 @@
/* Тема применяется до отрисовки, чтобы не было вспышки светлой темы. */
(function () {
var t;
try { t = localStorage.getItem('lms-theme') || 'light'; } catch (e) { t = 'light'; }
document.documentElement.setAttribute('data-theme', t);
})();

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<!-- Фрагмент большой модалки «Выдача вариантов» на странице группы.
Рендерится отдельно (без layout) и подставляется в #assign-modal-body через fetch. -->
<body>
<div th:fragment="content">
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<form class="inline-form js-assign-form" th:action="@{/admin/labs/{labId}/distribute(labId=${lab.id})}" method="post"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupIds" th:value="${groupId}">
<label class="tone-label"><input type="checkbox" name="force" value="true"> пересоздать заново</label>
<button class="btn btn-primary" type="submit">Распределить автоматически</button>
</form>
<span class="text-sm text-muted" th:text="|Занято вариантов: ${#maps.size(variantUsage)}|">—</span>
</div>
<div class="scroll-x">
<table>
<thead><tr>
<th class="th-sortable" title="ФИО — по возрастанию">Студент</th>
<th>Уровень</th><th>Вариант</th><th data-nosort>Источник</th><th data-nosort>Сдача</th>
</tr></thead>
<tbody>
<tr th:each="row : ${rows}">
<td>
<div class="font-medium" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</td>
<td>
<form class="inline-form js-assign-form" method="post"
th:action="@{/admin/labs/{labId}/set-level(labId=${lab.id})}"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input" name="labLevelId" th:disabled="${#lists.isEmpty(lab.levels)}" data-auto-submit>
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
th:selected="${lv.id == row.levelId}" th:text="${lv.title}">Уровень</option>
</select>
</form>
</td>
<td>
<form class="inline-form js-assign-form" method="post"
th:action="@{/admin/labs/{labId}/assign(labId=${lab.id})}"
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input js-variant-select" name="variantId">
<option value="">— авто —</option>
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
<option th:each="v : ${lv.variants}" th:value="${v.id}"
th:attr="data-taken=${variantUsage[v.id] ?: 0}"
th:selected="${v.id == row.variantId}"
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
</optgroup>
</select>
</form>
</td>
<td>
<span class="badge" th:classappend="${row.manual} ? 'badge-ok' : ''"
th:text="${row.manual} ? 'вручную' : 'авто'">авто</span>
</td>
<td>
<a th:if="${row.submissionId}" class="sub-link"
th:href="@{/admin/submissions/{id}/download(id=${row.submissionId})}">сдача ↓ <span th:text="${row.submissionSeq}">1</span></a>
<span th:unless="${row.submissionId}" class="sub-dim">не сдано</span>
</td>
</tr>
<tr th:if="${#lists.isEmpty(rows)}"><td colspan="5" class="empty">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -19,7 +19,7 @@
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="section-title">Лабораторные</h2> <h2 class="section-title">Лабораторные</h2>
<a th:href="@{/admin/labs}" class="text-sm">Все →</a> <a th:href="@{/admin/labs}" class="text-sm">Все лабораторные →</a>
</div> </div>
<div class="card card-flush"> <div class="card card-flush">
<table> <table>
@@ -36,7 +36,7 @@
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2> <h2 class="section-title">Группы</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все →</a> <a th:href="@{/admin/groups}" class="text-sm">Все группы →</a>
</div> </div>
<div class="card card-flush"> <div class="card card-flush">
<table> <table>

View File

@@ -3,55 +3,67 @@
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head> <head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body> <body>
<div id="content"> <div id="content">
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a> <div class="page-head">
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1> <p class="kicker">Ведомость группы</p>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p> <h1 class="page-title" th:text="${group.name}">Группа</h1>
<p class="meta">
<span th:text="${#lists.size(group.students)}">0</span> студентов ·
<b th:text="${publishedLabsCount}">0</b>
<span th:with="p=${publishedLabsCount}"
th:text="${(p % 10 == 1 and p % 100 != 11) ? 'лабораторная' : ((p % 10 >= 2 and p % 10 <= 4 and (p % 100 < 12 or p % 100 > 14)) ? 'лабораторные' : 'лабораторных')}">лабораторных</span>
с открытыми оценками
</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="actions">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/submissions.zip(id=${group.id})}"
title="ZIP со всеми последними решениями группы по всем лабораторным">Скачать работы</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт заданий</button> <button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2"> <div class="toolbar">
<h2 class="section-title m-0">Студенты и лабораторные</h2> <input class="input table-search w-260" data-target="group-table"
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}"> placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<input class="input input-sm table-search" data-target="group-table" <select class="input" id="lab-focus">
placeholder="Поиск по студенту…" aria-label="Поиск по студенту"> <option value="">все лабораторные</option>
<select class="input input-sm" id="lab-focus" onchange="focusLab()"> <option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
<option value="">все лабораторные</option> </select>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
</div> </div>
<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>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}"> <input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs)}"> <div class="card card-flush" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x"> <div class="scroll-x">
<table class="matrix tablekit" id="group-table"> <table class="matrix tablekit" id="group-table">
<thead> <thead>
<tr> <tr>
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th> <th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
<th th:each="lab : ${labs}" class="text-center lab-col" data-filter th:attr="data-lab=${lab.id}"> <th th:each="lab : ${labs}" class="text-center lab-col" th:attr="data-lab=${lab.id}">
<div class="lab-col-head"> <div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a> <a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions"> <div class="lab-head-actions">
<form class="inline-form" th:with="pub=${labPublication[lab.id]}" <button class="mini-btn" type="button"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<svg class="cell-ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post"> th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input type="hidden" name="published" <input class="js-pub-published" type="hidden" name="published"
th:value="${pub != null and pub.allPublished} ? 'false' : 'true'"> th:value="${all} ? 'false' : 'true'">
<button class="mini-btn" type="submit" <button class="mini-btn js-pub-eye" type="submit"
th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')" th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))"> th:classappend="${all} ? 'is-all' : (${pc > 0} ? 'is-partial' : 'is-empty')"
<svg th:if="${pub != null and pub.publishedCount > 0}" 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> th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<svg th:unless="${pub != null and pub.publishedCount > 0}" 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> <svg class="eye-open" 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>
<span class="mini-count" th:if="${pub != null and !pub.allPublished and pub.publishedCount > 0}" th:text="${pub.publishedCount}"></span> <svg class="eye-closed" 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>
<span class="mini-count js-pub-count" th:classappend="(${all} or ${pc == 0}) ? 'is-hidden' : ''" th:text="${pc}"></span>
</button> </button>
</form> </form>
<a class="mini-btn" <a class="mini-btn"
@@ -67,66 +79,67 @@
<tbody> <tbody>
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky"> <td class="student-cell matrix-sticky">
<div class="font-medium" th:text="${row.fullName}">—</div> <div class="student-name" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div> <div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</td> </td>
<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">
<div class="cell-top"> <div class="cell-main">
<button class="variant-btn" type="button" <button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''" th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}" th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}" data-action="variant"
onclick="openVariant(this)" title="Назначить вариант вручную или выдать случайный">
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button> <span th:if="${cell.variantNumber}" th:text="'В-' + ${cell.variantNumber}">В-1</span>
<span th:if="${cell.variantNumber == null}" class="cell-none">выдать</span>
</button>
<button class="score-link" type="button"
th:classappend="${cell.score == null} ? 'none' : ''"
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}" data-action="grade"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Оценки нет — выставить балл и комментарии'">
<span th:text="${cell.score} ?: '—'">—</span>
</button>
<input type="checkbox" class="pub-toggle" <input type="checkbox" class="pub-toggle"
th:checked="${cell.published}" th:checked="${cell.published}"
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}" th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'"> th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
</div> </div>
<div class="cell-btns"> <div class="cell-sub">
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение" <a th:if="${cell.submissionId}" class="sub-link"
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> th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">сдача ↓ <span th:text="${cell.submissionSeq}">1</span></a>
<span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span> <span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
<button class="cell-btn" type="button" <button class="cell-btn st js-defense-cell" 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},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode}" 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},data-reason=${cell.defenseReadyReason}" data-action="defense"
onclick="openDefense(this)" th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'"> <span class="cell-dim">защита:</span>
<span>🛡</span> <span class="js-def-label"
<span th:if="${cell.defenseStatus == null}">нет</span> th:text="${cell.defenseStatus == null ? '—' : (cell.defenseStatus == 'waiting'
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}">ожид.</span> ? (cell.defenseCode != null ? 'код ' + cell.defenseCode : 'ожидает')
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}">код</span> : (cell.defenseStatus == 'ready' ? 'готова' : ''))}">—</span>
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer" th:attr="data-seconds=${cell.defenseSecondsLeft}">идёт</span> <span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer js-def-timer" th:data-seconds="${cell.defenseSecondsLeft}">идёт</span>
<span th:if="${cell.defenseStatus == 'ready'}">готов</span>
</button> </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}"
onclick="openGradeEditor(this)"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Выставить балл и комментарии'">✎<span class="font-mono" th:if="${cell.score}" th:text="${cell.score}"></span></button>
</div> </div>
</div> </div>
</td> </td>
<td class="text-center"> <td class="text-center">
<div class="cell-stack"> <span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span> <span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span> </td>
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post"> <td class="text-right actions-cell">
<div class="actions-col">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit" <button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button> title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form> </form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form>
</div> </div>
</td> </td>
<td class="text-right actions-cell">
<form class="inline-form"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Отчислить</button>
</form>
</td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr> <tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
</tbody> </tbody>
@@ -138,7 +151,7 @@
<dialog id="modal-grade" class="modal modal-lg"> <dialog id="modal-grade" class="modal modal-lg">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2> <h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"> <form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
@@ -151,7 +164,7 @@
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div> <input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<div id="cs-rows"></div> <div id="cs-rows"></div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button> <button class="btn btn-secondary" type="button" data-action="comment-add">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button> <button class="btn btn-primary" type="submit">Сохранить</button>
</div> </div>
</form> </form>
@@ -163,7 +176,7 @@
<dialog id="modal-variant" class="modal modal-lg"> <dialog id="modal-variant" class="modal modal-lg">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Назначить вариант</h2> <h2 class="modal-title">Назначить вариант</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-variant')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body form-stack"> <div class="modal-body form-stack">
<p class="text-sm text-muted" id="variant-who">—</p> <p class="text-sm text-muted" id="variant-who">—</p>
@@ -199,7 +212,7 @@
<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>
<button class="modal-close" type="button" onclick="closeModal('modal-defense')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post"> <form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
@@ -228,8 +241,7 @@
<input type="hidden" name="groupId" th:value="${group.id}"> <input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button> <button class="btn btn-secondary" type="submit">Выдать код</button>
</form> </form>
<form class="inline-form" id="defense-reset-form" method="post" <form class="inline-form" id="defense-reset-form" method="post" data-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}">
@@ -243,39 +255,12 @@
<!-- Диалог импорта CSV -->
<dialog id="modal-defense-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт заданий</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/defenses/import}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="cimport-lab">Лабораторная</label>
<select class="input" id="cimport-lab" name="labId">
<option value="">— определить из файла (3-й столбец) —</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
</select></div>
<div><label class="label" for="cimport-file">Файл CSV</label>
<input class="input" id="cimport-file" type="file" name="file" accept=".csv,.txt" required></div>
<p class="hint">
Разделитель <code class="code">;</code>, <code class="code">,</code> или таб; заголовок не обязателен; кавычки поддерживаются.<br>
<b>2 столбца</b> (нужна выбранная лаба): <code class="code">студенческий_номер;задание</code>.<br>
<b>3 столбца</b>: <code class="code">студенческий_номер;номер_ЛР;задание</code> — лабораторная берётся из файла.<br>
Переносы внутри задания — как <code class="code">\n</code>.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
</dialog>
<!-- Диалог добавления студента --> <!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="modal"> <dialog id="modal-add-student" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Добавить студента</h2> <h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" id="enroll-form" method="post" <form class="form-stack" id="enroll-form" method="post"
@@ -315,7 +300,7 @@
<div th:if="${createdStudent}" class="card import-card toast"> <div th:if="${createdStudent}" class="card import-card">
<div class="card-body"> <div class="card-body">
<h2 class="section-title">Студент добавлен</h2> <h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p> <p class="text-sm" th:text="${createdStudent.fullName}">—</p>
@@ -324,7 +309,42 @@
</div> </div>
</div> </div>
<script src="/js/admin-group.js" defer></script> <!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
<div th:if="${importResult}" class="card import-card">
<div class="card-body">
<h2 class="section-title">
Группа <span th:text="${importResult.groupName}">—</span> импортирована
</h2>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.added)}">
Новых студентов: <b th:text="${#lists.size(importResult.added)}">0</b> — логины и одноразовые пароли:
</p>
<table th:if="${!#lists.isEmpty(importResult.added)}">
<thead><tr><th>Студент</th><th>Логин</th><th>Пароль</th></tr></thead>
<tbody>
<tr th:each="c : ${importResult.added}">
<td th:text="${c.fullName}">—</td>
<td class="font-mono" th:text="${c.studentNo}">—</td>
<td class="font-mono font-semibold" th:text="${c.password}">—</td>
</tr>
</tbody>
</table>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</p>
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
</div>
</div>
<!-- Большая модалка «Выдача вариантов»: содержимое подгружается фрагментом по лабе -->
<dialog id="modal-lab-assign" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="assign-modal-title">Выдача вариантов</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -3,31 +3,19 @@
<head><title>Группы — LMS</title></head> <head><title>Группы — LMS</title></head>
<body> <body>
<div id="content"> <div id="content">
<h1 class="page-title">Группы</h1> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="grid grid-2"> <h1 class="page-title m-0">Группы</h1>
<form class="card form-card form-stack" th:action="@{/admin/groups}" method="post"> <div class="flex gap-2">
<h2 class="section-title">Создать группу</h2> <input class="input table-search" data-target="groups-table"
<div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div> placeholder="Поиск по названию…" aria-label="Поиск по группам">
<button class="btn btn-primary" type="submit">Создать</button> <button class="btn btn-secondary" type="button" data-modal="modal-import-group">Импорт</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <button class="btn btn-primary" type="button" data-modal="modal-create-group">+ Новая группа</button>
</form> </div>
<form class="card form-card form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт студентов</h2>
<p class="text-sm text-muted">Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения.</p>
<div><label class="label" for="file">Файл со списком</label><input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label><input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 mt-8">
<h2 class="section-title m-0">Список групп</h2>
<input class="input input-sm table-search" data-target="groups-table"
placeholder="Поиск по названию…" aria-label="Поиск по группам">
</div> </div>
<div class="card card-flush"> <div class="card card-flush">
<table id="groups-table" class="tablekit"> <table id="groups-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Выгрузка</th></tr></thead> <thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Пароли</th></tr></thead>
<tbody> <tbody>
<tr th:each="group : ${groups}"> <tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td> <td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
@@ -38,6 +26,39 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<dialog id="modal-create-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая группа</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups}" method="post">
<div><label class="label" for="name">Название</label>
<input class="input" id="name" name="name" placeholder="ПИ-11БО" required autofocus></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-import-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт студентов</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<p class="text-sm text-muted">Файл .xls, .xlsx или .csv со столбцами: студенческий номер, ФИО, основа обучения. Логин — студенческий номер, пароль сгенерируется и покажется после импорта.</p>
<div><label class="label" for="file">Файл со списком</label>
<input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required autofocus></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label>
<input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,114 +0,0 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="|Настройка: ${lab.title} — LMS|">Настройка лабы</title></head>
<body>
<div id="content">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
<h1 class="page-title mt-4">Настройка лабораторной</h1>
<form class="form-stack" method="post" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<h2 class="section-title">Параметры</h2>
<div class="card form-card form-stack">
<div><label class="label" for="title">Название</label>
<input class="input" id="title" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="deadlineAt">Срок сдачи (сейчас: <span th:text="${deadlineMsk}" th:if="${deadlineMsk}">—</span> МСК)</label>
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="defenseAt">Дата защиты (сейчас: <span th:text="${defenseMsk}" th:if="${defenseMsk}">—</span> МСК)</label>
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="pointsMax">Максимум баллов</label>
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="status">Статус</label>
<select class="input" id="status" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft (черновик)</option>
<option value="published" th:selected="${lab.status == 'published'}">published (опубликована)</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14" th:text="${lab.descriptionMd}"></textarea></div>
</div>
<h2 class="section-title">Уровни</h2>
<div class="card card-flush">
<table>
<thead><tr><th class="th-w-sm">Код</th><th>Название</th><th></th></tr></thead>
<tbody>
<tr th:each="level : ${lab.levels}">
<td class="font-mono" th:text="${level.code}">basic</td>
<td>
<input type="hidden" name="levelIds" th:value="${level.id}">
<input class="input" name="levelTitles" th:value="${level.title}" required>
</td>
<td class="text-right">
<button class="btn btn-danger btn-sm" type="submit" name="action"
th:value="|delete-level-${level.id}|"
onclick="return confirm('Удалить уровень? Варианты на нём должны быть пустыми.')">
удалить</button>
</td>
</tr>
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровней нет — добавьте ниже.</td></tr>
</tbody>
</table>
</div>
<details class="card card-body mb-4">
<summary class="acc-head text-sm">+ Новый уровень</summary>
<div class="grid grid-2 mt-4">
<div><label class="label" for="nlCode">Код (латиницей)</label>
<input class="input" id="nlCode" name="newLevelCode" placeholder="pro"></div>
<div><label class="label" for="nlTitle">Название</label>
<input class="input" id="nlTitle" name="newLevelTitle" placeholder="Профессиональный"></div>
</div>
</details>
<h2 class="section-title">Варианты</h2>
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Сначала создайте уровень — затем варианты.</div>
<div th:each="level : ${lab.levels}" class="mb-4">
<h3 class="group-title" th:text="|${level.title} (${#lists.size(level.variants)})|">Уровень</h3>
<div th:each="v : ${level.variants}" class="card form-card mb-4">
<input type="hidden" name="variantIds" th:value="${v.id}">
<input type="hidden" name="variantLevelIds" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label>
<input class="input" name="variantNumbers" th:value="${v.number}" required></div>
<div><label class="label">Название</label>
<input class="input" name="variantTitles" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="variantStatements" rows="6" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-danger btn-sm" type="submit" name="action"
th:value="|delete-variant-${v.id}|"
onclick="return confirm('Удалить вариант?')">Удалить вариант</button>
</div>
</div>
<details class="card card-body mb-4" th:if="${!#lists.isEmpty(lab.levels)}">
<summary class="acc-head text-sm">+ Добавить вариант</summary>
<div class="grid grid-2 mt-4">
<div><label class="label" for="avLevel">Уровень</label>
<select class="input" id="avLevel" name="addLevelId">
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}">—</option>
</select></div>
<div><label class="label" for="avNumber">Номер</label>
<input class="input" id="avNumber" name="addNumber" placeholder="21"></div>
</div>
<div><label class="label" for="avTitle">Название</label>
<input class="input" id="avTitle" name="addTitle"></div>
<div><label class="label" for="avStatement">Условие (markdown)</label>
<textarea class="input" id="avStatement" name="addStatementMd" rows="6"></textarea></div>
</details>
<div class="flex gap-2 mb-8">
<button class="btn btn-primary" type="submit" name="action" value="save">Сохранить всё</button>
<button class="btn btn-secondary" type="submit" name="action" value="add-level">Добавить уровень</button>
<button class="btn btn-secondary" type="submit" name="action" value="add-variant">Добавить вариант</button>
</div>
</form>
</div>
</body>
</html>

View File

@@ -4,198 +4,222 @@
<body> <body>
<div id="content"> <div id="content">
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a> <a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6"> <div class="page-head mb-2">
<div> <div>
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1> <h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted"> <div class="meta mb-2">
Статус: <span class="badge"
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post"> th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">Черновик</span>
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}"
method="post"
data-confirm="Сменить статус лабораторной? Опубликованную увидят студенты, черновик — только вы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')" <button class="btn btn-sm" type="submit"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать
th:text="${lab.status}">draft</button> </button>
</form> </form>
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
<span th:if="${assignedCount > 0}" class="chip chip-neu">Вариантов назначено: <span th:text="${assignedCount}"></span></span>
<span th:if="${totalVariants > 0}" class="chip chip-neu">Занято вариантов: <span th:text="${occupiedVariants}"></span> из <span th:text="${totalVariants}"></span></span>
<span th:if="${totalVariants > 0}" class="chip chip-pos">Свободно: <span th:text="${freeVariants}"></span></span>
<span th:if="${submissionCount > 0}" class="chip chip-pos">Сдано: <span th:text="${submissionCount}"></span></span>
</p>
</div>
<div class="flex gap-2">
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
<button class="btn btn-primary" type="button" onclick="openModal('modal-distribute')">Распределить</button>
</div>
</div>
<h2 class="section-title">Условие лабораторной</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Назначения студентов</h2>
<p class="text-sm text-muted m-0">
Смена уровня сразу подбирает вариант; конкретный вариант можно закрепить вручную.
</p>
</div>
<div th:if="${#lists.isEmpty(assignmentGroups)}" class="card card-body mb-6 text-muted">Нет студентов для назначения.</div>
<div class="card card-body mb-4" th:if="${!#lists.isEmpty(assignmentGroups)}">
<div class="flex flex-wrap items-center gap-4">
<label class="tone-label"><input type="checkbox" id="batch-all"
onchange="toggleAllStudents(this)"> Выбрать всех</label>
<form id="batch-level-form" class="inline-form" method="post"
th:action="@{/admin/labs/{id}/batch-level(id=${lab.id})}"
onsubmit="return collectPicked(this)">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<select name="labLevelId" class="input input-sm" th:disabled="${#lists.isEmpty(lab.levels)}">
<option th:each="lv : ${lab.levels}" th:value="${lv.id}" th:text="${lv.title}">Уровень</option>
</select>
<button class="btn btn-secondary btn-sm" type="submit">Назначить уровень выбранным</button>
</form>
<form id="batch-variant-form" class="inline-form" method="post"
th:action="@{/admin/labs/{id}/batch-variant(id=${lab.id})}"
onsubmit="return collectPicked(this)">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<select name="variantId" class="input input-sm">
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
<option th:each="v : ${lv.variants}" th:value="${v.id}"
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
</optgroup>
</select>
<button class="btn btn-secondary btn-sm" type="submit">Закрепить вариант выбранным</button>
</form>
</div>
</div>
<div th:each="g, gStat : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h3 class="group-title m-0" th:text="${g.groupName}">Группа</h3>
<input class="input input-sm table-search" th:attr="data-target='assign-table-' + ${gStat.index}"
placeholder="Поиск по студенту…" aria-label="Поиск по студентам группы">
</div> </div>
<div class="scroll-x"> <span th:if="${deadlineMsk != null}">Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<table class="tablekit" th:attr="id='assign-table-' + ${gStat.index}"> <span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
<thead><tr> · Максимум: <span th:text="${lab.pointsMax}"></span> б.
<th class="th-w-sm" data-nosort>Выбор</th><th class="th-sortable is-asc" title="ФИО — по возрастанию">Студент</th><th class="th-w-md" data-filter>Уровень</th><th class="th-w-md">Вариант</th> <span th:if="${totalVariants > 0}"> · Вариантов занято: <span th:text="${occupiedVariants}"></span> из <span
<th class="th-w-sm" data-filter>Источник</th><th data-filter>Сдача</th> th:text="${totalVariants}"></span></span>
</tr></thead> <span th:if="${submissionCount > 0}"> · Сдач: <span th:text="${submissionCount}"></span></span>
<tbody> </div>
<tr th:each="s : ${g.students}"> </div>
<td><input type="checkbox" class="pick-student" th:value="${s.studentId}"></td>
<td> <div class="flex items-center gap-2 mb-2">
<div th:text="${s.fullName}">—</div> <h2 class="section-title m-0">Условие лабораторной</h2>
<div class="text-sm text-muted font-mono" th:text="${s.studentNo}">—</div> <button class="mini-btn" type="button" data-action="lab-edit"
</td> title="Редактировать параметры лабораторной" aria-label="Редактировать параметры лабораторной">
<td> <svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<form class="inline-form" method="post" stroke-linecap="round" stroke-linejoin="round">
th:action="@{/admin/labs/{id}/set-level(id=${lab.id})}"> <path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> </svg>
<input type="hidden" name="studentId" th:value="${s.studentId}"> </button>
<input type="hidden" name="labId" th:value="${lab.id}"> </div>
<select name="labLevelId" class="input input-sm" onchange="this.form.submit()" <div class="card card-body md-body mb-6" id="lab-description-view" th:utext="${descriptionHtml}"></div>
th:disabled="${#lists.isEmpty(lab.levels)}"> <form id="lab-edit-form" hidden class="card card-body form-stack mb-6" method="post"
<option th:each="lv : ${lab.levels}" th:value="${lv.id}" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
th:selected="${lv.id == s.levelId}" th:text="${lv.title}">Уровень</option> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</select> <div class="grid grid-2">
</form> <div><label class="label" for="l-title">Название</label>
</td> <input class="input" id="l-title" name="title" th:value="${lab.title}" required></div>
<td> <div><label class="label" for="l-points">Максимум баллов</label>
<form class="inline-form" method="post" <input class="input" id="l-points" name="pointsMax" type="number" step="0.1" min="0"
th:action="@{/admin/labs/{id}/assign(id=${lab.id})}"> th:value="${lab.pointsMax}" required></div>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> </div>
<input type="hidden" name="studentId" th:value="${s.studentId}"> <div class="grid grid-2">
<input type="hidden" name="labId" th:value="${lab.id}"> <div><label class="label" for="l-deadline">Срок сдачи (время — МСК)</label>
<select name="variantId" class="input input-sm pick-variant" onchange="confirmOccupied(this)"> <input class="input" id="l-deadline" name="deadlineAt" type="datetime-local"
<option value="">— авто —</option> th:value="${deadlineLocal}"></div>
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}"> <div><label class="label" for="l-defense">Дата защиты (время — МСК)</label>
<option th:each="v : ${lv.variants}" th:value="${v.id}" <input class="input" id="l-defense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}">
th:attr="data-taken=${variantUsage[v.id] ?: 0}"
th:selected="${v.id == s.variantId}"
th:text="${(variantUsage[v.id] ?: 0) > 1 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
</optgroup>
</select>
</form>
</td>
<td>
<span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-ok' : 'badge-warn'"
th:text="${s.source}">авто</span>
</td>
<td th:attr="data-filter-value=${s.submissionId != null ? 'сдано' : 'нет'}">
<a th:if="${s.submissionId}" class="cell-link"
th:href="@{/admin/submissions/{id}/download(id=${s.submissionId}, group=${g.groupName}, no=${s.studentNo}, name=${s.fullName}, attempt=${s.submissionSeq})}">
попытка <span th:text="${s.submissionSeq}">1</span> ↓</a>
<div th:if="${!#lists.isEmpty(s.submissionFiles)}" class="text-muted text-sm font-mono">
<span th:text="${#strings.listJoin(s.submissionFiles, ', ')}">main.c</span>
</div>
<span th:unless="${s.submissionId}" class="text-muted">—</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> <div><label class="label" for="l-status">Статус</label>
<select class="input" id="l-status" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">Черновик</option>
<option value="published" th:selected="${lab.status == 'published'}">Опубликована</option>
<option value="archived" th:selected="${lab.status == 'archived'}">В архиве</option>
</select></div>
<div><label class="label" for="l-desc">Условие (markdown)</label>
<textarea class="input" id="l-desc" name="descriptionMd" rows="14"
th:text="${lab.descriptionMd}"></textarea></div>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" type="button" data-action="lab-edit-cancel">Отмена</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
<h2 class="section-title">Уровни и варианты</h2> <h2 class="section-title">Уровни и варианты</h2>
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Уровней пока нет — добавьте на экране настройки.</div> <div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">
Уровней пока нет — добавьте их при создании лабораторной или импорте.
</div>
<div th:each="level : ${lab.levels}" class="card mb-4"> <div th:each="level : ${lab.levels}" class="card mb-4">
<div class="card-body"> <div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-2"> <div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h3 class="section-title" th:text="${level.title}">Уровень</h3> <h3 class="section-title m-0" th:text="${level.title}">Уровень</h3>
<span class="text-sm text-muted" th:text="|Вариантов: ${#lists.size(level.variants)}|">0</span> <div class="flex items-center gap-2">
<button class="mini-btn" type="button" th:attr="data-modal=|modal-level-rename-${level.id}|"
title="Переименовать уровень" aria-label="Переименовать уровень">
<svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</svg>
</button>
<form class="inline-form"
th:action="@{/admin/labs/{labId}/levels/{levelId}/delete(labId=${lab.id}, levelId=${level.id})}"
method="post" data-confirm="Удалить уровень? Варианты на уровне должны быть пустыми.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="mini-btn danger" type="submit" title="Удалить уровень"
aria-label="Удалить уровень">
<svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</form>
<button class="btn btn-sm" type="button" th:attr="data-modal=|modal-variant-add-${level.id}|">+
Добавить
</button>
</div>
</div> </div>
<div class="scroll-x">
<table> <div th:each="v : ${level.variants}" class="variant-block mb-4">
<thead><tr><th class="th-w-sm">№</th><th class="th-w-md">Название</th><th>Условие</th></tr></thead> <div class="flex flex-wrap items-center justify-between gap-2">
<tbody> <div class="variant-name">Вариант <b th:text="${v.number}">—</b><span th:if="${v.title}"
<tr th:each="v : ${level.variants}"> class="text-muted"
<td class="font-mono" th:text="${v.number}">1</td> th:text="' · ' + ${v.title}"></span>
<td th:text="${v.title}">—</td> </div>
<td class="text-muted preview" th:text="${#strings.abbreviate(v.statementMd, 70)}">—</td> <div class="flex gap-2">
</tr> <button class="mini-btn" type="button" th:attr="data-modal=|modal-variant-edit-${v.id}|"
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="3" class="empty">Вариантов нет.</td></tr> title="Редактировать вариант" aria-label="Редактировать вариант">
</tbody> <svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
</table> stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</svg>
</button>
<form class="inline-form"
th:action="@{/admin/labs/{labId}/variants/{variantId}/delete(labId=${lab.id}, variantId=${v.id})}"
method="post" data-confirm="Удалить вариант вместе с его условием?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="mini-btn danger" type="submit" title="Удалить вариант"
aria-label="Удалить вариант">
<svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</form>
</div>
</div>
<div class="md-body mt-2" th:utext="${variantHtml[v.id]}"></div>
</div> </div>
<p th:if="${#lists.isEmpty(level.variants)}" class="text-sm text-muted m-0">Вариантов пока нет.</p>
</div> </div>
</div> </div>
<dialog id="modal-distribute" class="modal"> <div th:each="level : ${lab.levels}" th:remove="tag">
<div class="modal-header"> <dialog th:id="|modal-level-rename-${level.id}|" class="modal">
<h2 class="modal-title">Распределение вариантов</h2> <div class="modal-header">
<button class="modal-close" type="button" onclick="closeModal('modal-distribute')">✕</button> <h2 class="modal-title">Переименовать уровень</h2>
</div> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
<div class="modal-body"> </div>
<form class="form-stack" th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post"> <div class="modal-body">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<p class="text-sm"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
Студенты выбранных групп раздаются одной общей очерёдностью. Каждый получает <input type="hidden" name="levelId" th:value="${level.id}">
вариант <b>со своего уровня</b> (без уровня — с первого уровня лабораторной): <div><label class="label" for="|lvl-title-${level.id}|">Название</label>
пока внутри уровня есть не выданные никому варианты — они выдаются без повторов, <input class="input" th:id="|lvl-title-${level.id}|" name="title" th:value="${level.title}"
затем вариант берётся из наименее занятых. Ручные закрепления сохраняются всегда. required></div>
</p> <button class="btn btn-primary" type="submit">Сохранить</button>
<div th:if="${!#lists.isEmpty(groups)}"> </form>
<label class="label">Группы (ничего не выбрано — все группы)</label> </div>
<div class="check-list"> </dialog>
<label class="tone-label" th:each="g : ${groups}"> <dialog th:id="|modal-variant-add-${level.id}|" class="modal">
<input type="checkbox" name="groupIds" th:value="${g.id}"> <div class="modal-header">
<span th:text="${g.name}">—</span> <h2 class="modal-title" th:text="|Новый вариант — ${level.title}|">Новый вариант</h2>
</label> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}"
method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label" for="|va-number-${level.id}|">Номер</label>
<input class="input" th:id="|va-number-${level.id}|" name="number" required></div>
<div><label class="label" for="|va-title-${level.id}|">Название</label>
<input class="input" th:id="|va-title-${level.id}|" name="title"></div>
</div> </div>
<div><label class="label" for="|va-statement-${level.id}|">Условие (markdown)</label>
<textarea class="input" th:id="|va-statement-${level.id}|" name="statementMd"
rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
<th:block th:each="v : ${level.variants}">
<dialog th:id="|modal-variant-edit-${v.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title" th:text="|Вариант ${v.number}|">Вариант</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<label class="tone-label"> <div class="modal-body">
<input type="checkbox" name="force" value="true"> <form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}"
Пересоздать заново (сбросить авто-назначения у выбранных; вручную закреплённые останутся) method="post">
</label> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Распределить</button> <input type="hidden" name="variantId" th:value="${v.id}">
</form> <input type="hidden" name="labLevelId" th:value="${level.id}">
</div> <div class="grid grid-2">
</dialog> <div><label class="label" for="|ve-number-${v.id}|">Номер</label>
<input class="input" th:id="|ve-number-${v.id}|" name="number" th:value="${v.number}"
<script src="/js/admin-lab.js" defer></script> required></div>
<div><label class="label" for="|ve-title-${v.id}|">Название</label>
<input class="input" th:id="|ve-title-${v.id}|" name="title" th:value="${v.title}">
</div>
</div>
<div><label class="label" for="|ve-statement-${v.id}|">Условие (markdown)</label>
<textarea class="input" th:id="|ve-statement-${v.id}|" name="statementMd" rows="10"
th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
</th:block>
</div>
<script th:src="@{/js/admin-lab.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -6,34 +6,44 @@
<div class="flex items-center justify-between gap-4 mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1> <h1 class="page-title">Лабораторные</h1>
<div class="flex gap-2"> <div class="flex gap-2">
<input class="input input-sm table-search" data-target="labs-table" <input class="input table-search" data-target="labs-table"
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным"> placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button> <button class="btn btn-secondary" type="button" data-modal="modal-import-lab">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button> <button class="btn btn-primary" type="button" data-modal="modal-create-lab">+ Создать</button>
</div> </div>
</div> </div>
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
<div class="card card-flush"> <div class="card card-flush">
<table id="labs-table" class="tablekit"> <table id="labs-table" class="tablekit">
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort></th></tr></thead> <thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th data-filter>Статус</th><th data-nosort>Действие</th><th data-nosort></th></tr></thead>
<tbody> <tbody>
<tr th:each="lab : ${labs}"> <tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</td> <td th:text="${lab.number}">1</td>
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td> <td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td th:text="${lab.variantCount}">0</td> <td th:text="${lab.variantCount}">0</td>
<td th:attr="data-filter-value=${lab.status}"> <td th:attr="data-filter-value=${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post"> <span class="badge"
<input type="hidden" name="_csrf" th:value="${csrfToken}"> th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
<button class="badge badge-btn" th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">—</span>
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" </td>
type="submit" th:text="${lab.status}">—</button> <td class="text-center">
</form> <div class="actions-col">
<form th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post" data-confirm="Сменить статус лабораторной? Опубликованную увидят студенты, черновик — только вы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="@{/admin/labs}">
<button class="btn btn-sm"
th:classappend="${lab.status == 'published'} ? 'btn-secondary' : 'btn-primary'"
type="submit"
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">—</button>
</form>
<form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post" data-confirm="Удалить лабораторную вместе с вариантами и назначениями? Действие необратимо.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
</form>
</div>
</td> </td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger btn-sm" type="submit">Удалить</button></form></td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr> <tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -42,7 +52,7 @@
<dialog id="modal-import-lab" class="modal"> <dialog id="modal-import-lab" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Импорт лабораторной</h2> <h2 class="modal-title">Импорт лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import-lab')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data"> <form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
@@ -65,7 +75,7 @@
<dialog id="modal-create-lab" class="modal"> <dialog id="modal-create-lab" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2> <h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post"> <form class="form-stack" th:action="@{/admin/labs/create}" method="post">

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Пользователи — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Пользователи</h1>
<form class="flex flex-wrap items-center gap-2 mb-4" method="get" th:action="@{/admin/users}">
<input class="input" name="q" th:value="${query}" placeholder="Поиск по ФИО, логину, номеру или группе…" aria-label="Поиск пользователей">
<button class="btn btn-secondary" type="submit">Найти</button>
<a class="btn btn-secondary" th:href="@{/admin/users}" th:if="${!#strings.isEmpty(query)}">Сбросить</a>
<span class="text-sm text-muted">Найдено: <span th:text="${#lists.size(users)}">0</span></span>
</form>
<div class="card card-flush">
<table id="users-table" class="tablekit">
<thead>
<tr>
<th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th>
<th>Логин</th>
<th>Номер</th>
<th>Группа</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="u : ${users}">
<td class="font-medium" th:text="${u.fullName}">—</td>
<td class="font-mono" th:text="${u.login}">—</td>
<td class="font-mono" th:text="${u.studentNo}">—</td>
<td><a th:if="${u.groupId}" th:href="@{/admin/groups/{id}(id=${u.groupId})}" th:text="${u.groupName}">—</a><span th:unless="${u.groupId}" class="text-muted">без группы</span></td>
<td class="text-right">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post" data-confirm="Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="q" th:value="${query}">
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(users)}"><td colspan="5" class="empty">Никого не нашли.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -13,9 +13,9 @@
Смените пароль, если хотите. После сохранения вы останетесь в системе. Смените пароль, если хотите. После сохранения вы останетесь в системе.
</p> </p>
<form class="card form-card form-stack" th:action="@{/change-password}" method="post"> <form class="card form-card form-stack" th:action="@{/change-password}" method="post">
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" required></div> <div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" autocomplete="current-password" required></div>
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div> <div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" autocomplete="new-password" required></div>
<button class="btn btn-primary" type="submit">Сменить пароль</button> <button class="btn btn-primary" type="submit">Сменить пароль</button>
<a th:if="${currentUser != null and !currentUser.mustChangePassword}" <a th:if="${currentUser != null and !currentUser.mustChangePassword}"
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a> class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>

View File

@@ -4,102 +4,49 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">LMS</title> <title th:replace="${title}">LMS</title>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" th:href="@{/css/app.css}"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css}"> <!-- Inter с запасным системным гротеском. Загрузка не блокирует рендер:
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css}" disabled> при недоступном CDN страница открывается на системном шрифте. -->
<script th:src="@{/js/htmx.min.js}" defer></script> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
<script th:src="@{/js/highlight.min.js}" defer></script> rel="stylesheet" media="print" onload="this.media='all'">
<script th:src="@{/js/table-scroll.js}" defer></script> <noscript>
<script th:src="@{/js/tablekit.js}" defer></script> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script th:src="@{/js/ui-state.js}" defer></script> </noscript>
<script th:src="@{/js/md-content.js}" defer></script> <link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg(v='2')}">
<script> <!-- v=4: сброс кэша стилей после редизайна; при изменении css поднимать версию -->
(function(){ <link rel="stylesheet" th:href="@{/css/app.css(v='10')}">
var t = localStorage.getItem('lms-theme') || 'light'; <link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='10')}">
document.documentElement.setAttribute('data-theme', t); <link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='10')}" disabled>
window.__hljsSet = function(theme){ <script th:src="@{/js/highlight.min.js(v='10')}" defer></script>
var l = document.getElementById('hljs-light'), d = document.getElementById('hljs-dark'); <script th:src="@{/js/table-scroll.js(v='10')}" defer></script>
if (l && d) { l.disabled = theme !== 'light'; d.disabled = theme !== 'dark'; } <script th:src="@{/js/tablekit.js(v='10')}" defer></script>
}; <script th:src="@{/js/theme-init.js(v='10')}"></script>
window.__hljsAll = function(){ <script th:src="@{/js/app.js(v='10')}" defer></script>
if (!window.hljs) return; <script th:src="@{/js/ui-state.js(v='10')}" defer></script>
document.querySelectorAll('pre code').forEach(function(el){ window.hljs.highlightElement(el); }); <script th:src="@{/js/md-content.js(v='10')}" defer></script>
}; <script th:src="@{/js/submit-guard.js(v='10')}" defer></script>
})();
document.addEventListener('DOMContentLoaded', function(){ window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); syncModalScrollLock(); });
function toggleTheme(){
var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark';
e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); window.__hljsSet(t);
}
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); syncModalScrollLock(); }
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); syncModalScrollLock(); }
function openModalData(btn){ openModal(btn.dataset.modal); }
function closeModalData(btn){ closeModal(btn.dataset.modal); }
/* Пока открыта хоть одна модалка — блокируем прокрутку страницы, сохраняя позицию скролла
(внутри .modal-body прокрутка остаётся). */
var modalScrollY = 0;
function syncModalScrollLock(){
var root = document.documentElement;
if (document.querySelector('dialog[open]')) {
if (root.classList.contains('modal-open')) return;
modalScrollY = window.scrollY || window.pageYOffset || 0;
var sw = window.innerWidth - root.clientWidth;
root.style.setProperty('--modal-scrollbar', (sw > 0 ? sw : 0) + 'px');
root.style.setProperty('--modal-top', (-modalScrollY) + 'px');
root.classList.add('modal-open');
} else if (root.classList.contains('modal-open')) {
root.classList.remove('modal-open');
root.style.removeProperty('--modal-scrollbar');
root.style.removeProperty('--modal-top');
window.scrollTo(0, modalScrollY);
}
}
document.addEventListener('toggle', function (e) {
if (e.target && e.target.tagName === 'DIALOG') syncModalScrollLock();
}, true);
function sectionStateKey(id){ return 'lms-section-open:' + id; }
function applySectionState(acc){
var id = acc.getAttribute('data-section-id');
var stored = null;
try { stored = id ? localStorage.getItem(sectionStateKey(id)) : null; } catch (e) { /* приватный режим */ }
var open = stored === '1' ? true
: stored === '0' ? false
: acc.getAttribute('data-default-open') === 'true';
acc.classList.toggle('collapsed', !open);
var btn = acc.querySelector('.sub-toggle');
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function initSections(){
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
function toggleSection(btn){
var acc = btn.closest('.sub-acc');
if (!acc) return;
var collapsed = acc.classList.toggle('collapsed');
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
var id = acc.getAttribute('data-section-id');
if (id) { try { localStorage.setItem(sectionStateKey(id), collapsed ? '0' : '1'); } catch (e) { /* приватный режим */ } }
}
document.addEventListener('DOMContentLoaded', initSections);
</script>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<header class="page-header"> <header class="page-header">
<div class="page-header-inner"> <div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS</a> <a th:href="@{/}" class="logo">LMS<b>.</b></a>
<nav class="nav"> <nav class="nav" id="top-nav">
<th:block th:if="${currentUser != null}"> <th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a> <a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/users}" class="nav-link">Пользователи</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a> <a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a> <a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a> <a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:unless="${currentUser.admin}" th:href="@{/student}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
</th:block> </th:block>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary btn-sm">Войти</a> </nav>
<button type="button" onclick="toggleTheme()" class="btn btn-secondary btn-sm theme-btn" <div class="user">
<th:block th:if="${currentUser != null}">
<a th:href="@{/admin}" th:if="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:unless="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
</th:block>
<button type="button" class="btn btn-secondary theme-btn"
aria-label="Сменить тему" title="Сменить тему"> aria-label="Сменить тему" title="Сменить тему">
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/> <circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/>
@@ -116,47 +63,50 @@
</g> </g>
</svg> </svg>
</button> </button>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn">Войти</a>
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post"> <form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
<button class="btn btn-secondary" type="submit">Выйти</button> <button class="btn" type="submit">Выйти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</nav> </div>
</div> </div>
</header> </header>
<main class="page-main"> <main class="page-main">
<th:block th:replace="${content}" /> <th:block th:replace="${content}" />
</main> </main>
<footer class="page-footer"> <footer class="page-footer">
LMS v0.2.0 LMS v1.1.0
</footer> </footer>
</div> </div>
<div class="toast-host" id="toast-host" aria-live="polite"> <div class="toast-host" id="toast-host" aria-live="polite">
<div th:if="${notice}" class="toast-item toast-success" data-toast> <div th:if="${notice}" class="toast-item toast-success" data-toast>
<span class="toast-text" th:text="${notice}">…</span> <span class="toast-text" th:text="${notice}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button> <button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div th:if="${error}" class="toast-item toast-error" data-toast> <div th:if="${error}" class="toast-item toast-error" data-toast>
<span class="toast-text" th:text="${error}">…</span> <span class="toast-text" th:text="${error}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button> <button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div th:if="${resetPassword}" class="toast-item toast-warning" data-toast> <div th:if="${resetPassword}" class="toast-item toast-warning" data-toast>
<span class="toast-text">Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code></span> <span class="toast-text">
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button> Новый пароль<span th:if="${resetFor}" th:text="| для ${resetFor}|"> для Иванов И.</span>:
<code class="code font-mono" th:text="${resetPassword}">password</code>
</span>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
</div> </div>
<script> <dialog id="modal-confirm" class="modal">
document.addEventListener('DOMContentLoaded', function () { <div class="modal-header">
document.querySelectorAll('[data-toast]').forEach(function (toast) { <h2 class="modal-title" id="confirm-title">Подтверждение</h2>
var timer = setTimeout(function () { toast.remove(); }, 30000); <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
var close = toast.querySelector('.toast-close'); </div>
if (close) { <div class="modal-body">
close.addEventListener('click', function () { <p class="confirm-text" id="confirm-text">—</p>
clearTimeout(timer); <div class="flex justify-end gap-2 mt-4">
toast.remove(); <button class="btn btn-secondary" type="button" id="confirm-cancel">Отмена</button>
}); <button class="btn btn-primary" type="button" id="confirm-ok">ОК</button>
} </div>
}); </div>
}); </dialog>
</script>
</body> </body>
</html> </html>

View File

@@ -46,34 +46,32 @@
</h1> </h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4"> <div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="startArticleEdit()">Править</button> <button class="btn btn-primary" type="button" data-action="article-edit">Править</button>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}"> <input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}"> <input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${article.published}" title="Скрыть статью" aria-label="Скрыть статью"><svg class="cell-ico" 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></button>
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${article.published}" title="Опубликовать статью" aria-label="Опубликовать статью"><svg class="cell-ico" 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>
th:text="${article.published} ? 'Скрыть от студентов' : 'Опубликовать'">Скрыть</button>
</form> </form>
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary"
th:href="@{/learn/{c}(c=${article.chapterSlug})}">К главе</a> th:href="@{/learn/{c}(c=${article.chapterSlug})}">К главе</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" <form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}"> <input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить статью</button> <button class="mini-btn danger" type="submit" title="Удалить статью" aria-label="Удалить статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
<nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}"> <nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary btn-sm" <a th:if="${prevArticle != null}" class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}" th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}"
th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|"> th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
← <span th:text="${prevArticle.title}">Предыдущая</span> ← <span th:text="${prevArticle.title}">Предыдущая</span>
</a> </a>
<span th:if="${prevArticle == null}"></span> <span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-secondary btn-sm ml-auto" <a th:if="${nextArticle != null}" class="btn btn-secondary ml-auto"
th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}" th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}"
th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|"> th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
<span th:text="${nextArticle.title}">Следующая</span> → <span th:text="${nextArticle.title}">Следующая</span> →
@@ -90,8 +88,8 @@
<div class="md-editor-head"> <div class="md-editor-head">
<span class="md-editor-title">Редактирование статьи</span> <span class="md-editor-title">Редактирование статьи</span>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="cancelArticleEdit()">Отмена</button> <button class="btn btn-secondary" type="button" data-action="article-edit-cancel">Отмена</button>
<button class="btn btn-primary btn-sm" type="submit">Сохранить</button> <button class="btn btn-primary" type="submit">Сохранить</button>
</div> </div>
</div> </div>
<div class="card-body form-stack"> <div class="card-body form-stack">
@@ -126,7 +124,7 @@
</div> </div>
<script src="/js/mdeditor.js" defer></script> <script th:src="@{/js/mdeditor.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -15,27 +15,25 @@
</h1> </h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4"> <div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-section')">+ Раздел</button> <button class="btn btn-primary" type="button" data-modal="modal-new-section">+ Раздел</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-chapter')">Переименовать главу</button> <button class="mini-btn" type="button" data-modal="modal-rename-chapter" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}"> <input type="hidden" name="id" th:value="${chapter.id}">
<input type="hidden" name="published" th:value="${!chapter.published}"> <input type="hidden" name="published" th:value="${!chapter.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${chapter.published}" title="Скрыть главу" aria-label="Скрыть главу"><svg class="cell-ico" 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></button>
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${chapter.published}" title="Опубликовать главу" aria-label="Опубликовать главу"><svg class="cell-ico" 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>
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть</button>
</form> </form>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" <form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}"> <input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button> <button class="mini-btn danger" type="submit" title="Удалить главу со всеми разделами и статьями" aria-label="Удалить главу со всеми разделами и статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4"> <div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Перетаскивайте за значок ⠿ слева, чтобы менять порядок разделов и переносить статьи Перетаскивайте за значок руки слева, чтобы менять порядок разделов и переносить статьи
между разделами этой главы. Порядок сохраняется сразу. между разделами этой главы. Порядок сохраняется сразу.
</div> </div>
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden> <div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
@@ -52,8 +50,8 @@
<div class="sub-head sort-row"> <div class="sub-head sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}" th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок разделов">⠿</span> title="Перетащите мышью: изменить порядок разделов"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)" <button type="button" class="sub-toggle" aria-expanded="false"
title="Свернуть или развернуть раздел"> title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span> <span class="sub-arrow" aria-hidden="true"></span>
</button> </button>
@@ -62,24 +60,20 @@
th:text="${section.title}">Раздел</a> th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span> <span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions"> <div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<button class="btn btn-secondary btn-sm" type="button" <button class="mini-btn" type="button" th:attr="data-modal=|modal-rename-section-${section.id}|" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}"> <input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}"> <input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" 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></button>
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" 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>
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form> </form>
<button class="btn btn-secondary btn-sm" type="button" <button class="mini-btn" type="button" th:attr="data-modal=|modal-move-${section.id}|" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button> <form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел вместе со статьями?">
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button> <button class="mini-btn danger" type="submit" title="Удалить раздел вместе со статьями" aria-label="Удалить раздел вместе со статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
</div> </div>
@@ -89,22 +83,21 @@
<li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}"> <li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='article',data-drag-id=${article.id}" th:attr="data-drag-kind='article',data-drag-id=${article.id}"
title="Перетащите мышью: изменить порядок или перенести в другой раздел">⠿</span> title="Перетащите мышью: изменить порядок или перенести в другой раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<a class="sort-title" <a class="sort-title"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}" th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a> th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span> <span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions"> <div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a> th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}"> <input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}"> <input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${article.published}" title="Скрыть статью" aria-label="Скрыть статью"><svg class="cell-ico" 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></button>
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${article.published}" title="Опубликовать статью" aria-label="Опубликовать статью"><svg class="cell-ico" 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>
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form> </form>
</div> </div>
</li> </li>
@@ -116,8 +109,8 @@
<dialog th:id="|modal-rename-section-${section.id}|" class="modal"> <dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2> <h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" <button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button> th:attr="data-modal=|modal-rename-section-${section.id}|">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -132,14 +125,17 @@
<dialog th:id="|modal-move-${section.id}|" class="modal"> <dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2> <h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button" <button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button> th:attr="data-modal=|modal-move-${section.id}|">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p> <p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
<p th:if="${#lists.size(sectionChoices) < 2}" class="text-sm text-muted">
<b>Других разделов нет</b> — при подтверждении статьи будут удалены вместе с разделом.
</p>
<div><label class="label">Перенести в раздел</label> <div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo"> <select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}" <option th:each="pair : ${sectionChoices}"
@@ -157,7 +153,7 @@
<dialog id="modal-rename-chapter" class="modal"> <dialog id="modal-rename-chapter" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2> <h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-chapter')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
@@ -173,7 +169,7 @@
<dialog id="modal-new-section" class="modal"> <dialog id="modal-new-section" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Новый раздел в главе</h2> <h2 class="modal-title">Новый раздел в главе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-section')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">
@@ -187,7 +183,7 @@
</div> </div>
</dialog> </dialog>
<script src="/js/content-sort.js" defer></script> <script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -6,16 +6,11 @@
<div class="flex items-center justify-between gap-4 mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1> <h1 class="page-title">Учебные материалы</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2"> <div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button> <button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import')">Импорт zip</button> <button class="btn btn-secondary" type="button" data-modal="modal-import">Импорт zip</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button> <button class="btn btn-secondary" type="button" data-modal="modal-new-chapter">+ Глава</button>
</div> </div>
</div> </div>
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
и видны только вам. Перетаскивайте за значок ⠿ слева, чтобы менять порядок глав, разделов
и статей, а также переносить разделы в другую главу и статьи — в другой раздел.
</div>
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden> <div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
<span id="sort-status-text">Изменения не сохранены…</span> <span id="sort-status-text">Изменения не сохранены…</span>
</div> </div>
@@ -29,7 +24,7 @@
<div class="sort-row"> <div class="sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='chapter',data-drag-id=${chapter.id}" th:attr="data-drag-kind='chapter',data-drag-id=${chapter.id}"
title="Перетащите мышью: изменить порядок глав">⠿</span> title="Перетащите мышью: изменить порядок глав"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2> <h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span> <span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions"> <div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
@@ -37,16 +32,13 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}"> <input type="hidden" name="id" th:value="${chapter.id}">
<input type="hidden" name="published" th:value="${!chapter.published}"> <input type="hidden" name="published" th:value="${!chapter.published}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${chapter.published}" title="Скрыть главу" aria-label="Скрыть главу"><svg class="cell-ico" 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></button>
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${chapter.published}" title="Опубликовать главу" aria-label="Опубликовать главу"><svg class="cell-ico" 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>
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
</form> </form>
<button class="btn btn-secondary btn-sm" type="button" <button class="mini-btn" type="button" th:attr="data-modal=|modal-rename-chapter-${chapter.id}|" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</button> <form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button> <button class="mini-btn danger" type="submit" title="Удалить главу со всеми разделами и статьями" aria-label="Удалить главу со всеми разделами и статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
</div> </div>
@@ -59,8 +51,8 @@
<div class="sub-head sort-row"> <div class="sub-head sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}" th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок или перенести раздел">⠿</span> title="Перетащите мышью: изменить порядок или перенести раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)" <button type="button" class="sub-toggle" aria-expanded="false"
title="Свернуть или развернуть раздел"> title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span> <span class="sub-arrow" aria-hidden="true"></span>
</button> </button>
@@ -73,18 +65,14 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}"> <input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}"> <input type="hidden" name="published" th:value="${!section.published}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" 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></button>
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" 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>
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
</form> </form>
<button class="btn btn-secondary btn-sm" type="button" <button class="mini-btn" type="button" th:attr="data-modal=|modal-rename-section-${section.id}|" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button> <button class="mini-btn" type="button" th:attr="data-modal=|modal-move-${section.id}|" title="Перенести статьи в другой раздел" aria-label="Перенести статьи в другой раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<button class="btn btn-secondary btn-sm" type="button" <form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел со всеми статьями?">
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button> <button class="mini-btn danger" type="submit" title="Удалить раздел со всеми статьями" aria-label="Удалить раздел со всеми статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
</div> </div>
@@ -94,7 +82,7 @@
<li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}"> <li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='article',data-drag-id=${article.id}" th:attr="data-drag-kind='article',data-drag-id=${article.id}"
title="Перетащите мышью: изменить порядок или перенести статью">⠿</span> title="Перетащите мышью: изменить порядок или перенести статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<a class="sort-title" <a class="sort-title"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}" th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a> th:text="${article.title}">Статья</a>
@@ -104,16 +92,14 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}"> <input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}"> <input type="hidden" name="published" th:value="${!article.published}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${article.published}" title="Скрыть статью" aria-label="Скрыть статью"><svg class="cell-ico" 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></button>
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${article.published}" title="Опубликовать статью" aria-label="Опубликовать статью"><svg class="cell-ico" 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>
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form> </form>
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a> th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" <form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">✕</button> <button class="mini-btn danger" type="submit" title="Удалить статью" aria-label="Удалить статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
</li> </li>
@@ -125,8 +111,8 @@
<dialog th:id="|modal-rename-section-${section.id}|" class="modal"> <dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2> <h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" <button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button> th:attr="data-modal=|modal-rename-section-${section.id}|">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -140,8 +126,8 @@
<dialog th:id="|modal-move-${section.id}|" class="modal"> <dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2> <h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button" <button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button> th:attr="data-modal=|modal-move-${section.id}|">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
@@ -150,6 +136,7 @@
<div><label class="label">Перенести в раздел</label> <div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo"> <select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}" <option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option> th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div> </select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить раздел</button> <button class="btn btn-primary" type="submit">Перенести и удалить раздел</button>
@@ -164,8 +151,8 @@
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal"> <dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2> <h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" <button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</button> th:attr="data-modal=|modal-rename-chapter-${chapter.id}|">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
@@ -182,7 +169,7 @@
<dialog id="modal-import" class="modal"> <dialog id="modal-import" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Импорт материалов (zip)</h2> <h2 class="modal-title">Импорт материалов (zip)</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data"> <form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
@@ -208,7 +195,7 @@
<dialog id="modal-new-chapter" class="modal"> <dialog id="modal-new-chapter" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Новая глава</h2> <h2 class="modal-title">Новая глава</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-chapter')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post"> <form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
@@ -223,7 +210,7 @@
<dialog id="modal-new-article" class="modal"> <dialog id="modal-new-article" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Новая статья</h2> <h2 class="modal-title">Новая статья</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-article')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post"> <form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -245,8 +232,8 @@
</div> </div>
</dialog> </dialog>
<script src="/js/mdeditor.js" defer></script> <script th:src="@{/js/mdeditor.js(v='10')}" defer></script>
<script src="/js/content-sort.js" defer></script> <script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -17,28 +17,26 @@
</h1> </h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4"> <div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-article')">+ Статья</button> <button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-section')">Переименовать раздел</button> <button class="mini-btn" type="button" data-modal="modal-rename-section" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}"> <input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}"> <input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" 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></button>
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" 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>
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть</button>
</form> </form>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-move-section')">Перенести статьи и удалить</button> <button class="mini-btn" type="button" data-modal="modal-move-section" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" <form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел вместе со статьями?">
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}"> <input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button> <button class="mini-btn danger" type="submit" title="Удалить раздел вместе со статьями" aria-label="Удалить раздел вместе со статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form> </form>
</div> </div>
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4"> <div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Перетаскивайте статьи за значок ⠿, чтобы менять их порядок. Порядок сохраняется сразу. Перетаскивайте статьи за значок руки, чтобы менять их порядок. Порядок сохраняется сразу.
</div> </div>
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden> <div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
<span id="sort-status-text">Изменения не сохранены…</span> <span id="sort-status-text">Изменения не сохранены…</span>
@@ -52,28 +50,26 @@
<td> <td>
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true" <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='article',data-drag-id=${article.id}" th:attr="data-drag-kind='article',data-drag-id=${article.id}"
title="Перетащите, чтобы изменить порядок статей">⠿</span> title="Перетащите, чтобы изменить порядок статей"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}" <a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a> th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span> <span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td> </td>
<td class="text-right actions-cell"> <td class="text-right actions-cell">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a> th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post"> <form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}"> <input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}"> <input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="mini-btn" type="submit" th:if="${article.published}" title="Скрыть статью" aria-label="Скрыть статью"><svg class="cell-ico" 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></button>
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" <button class="mini-btn" type="submit" th:unless="${article.published}" title="Опубликовать статью" aria-label="Опубликовать статью"><svg class="cell-ico" 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>
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form> </form>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" <form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button> <button class="btn btn-danger" type="submit">Удалить</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -85,7 +81,7 @@
<dialog id="modal-rename-section" class="modal"> <dialog id="modal-rename-section" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2> <h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-section')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -101,13 +97,16 @@
<dialog id="modal-move-section" class="modal"> <dialog id="modal-move-section" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Перенести статьи и удалить раздел</h2> <h2 class="modal-title">Перенести статьи и удалить раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-move-section')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"> <form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}"> <input type="hidden" name="back" th:value="${backParent}">
<p class="text-sm text-muted">Выберите раздел-приёмник: его статьи перенесутся туда, а текущий раздел удалится.</p> <p class="text-sm text-muted">Выберите раздел-приёмник: его статьи перенесутся туда, а текущий раздел удалится.</p>
<p th:if="${#lists.size(sectionChoices) < 2}" class="text-sm text-muted">
<b>Других разделов нет</b> — при подтверждении статьи будут удалены вместе с разделом.
</p>
<div><label class="label" for="moveTo">Перенести в раздел</label> <div><label class="label" for="moveTo">Перенести в раздел</label>
<select class="input" id="moveTo" name="moveTo"> <select class="input" id="moveTo" name="moveTo">
<option th:each="pair : ${sectionChoices}" <option th:each="pair : ${sectionChoices}"
@@ -122,7 +121,7 @@
<dialog id="modal-new-article" class="modal"> <dialog id="modal-new-article" class="modal">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">Новая статья в разделе</h2> <h2 class="modal-title">Новая статья в разделе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-article')">✕</button> <button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post"> <form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -144,7 +143,7 @@
</div> </div>
</dialog> </dialog>
<script src="/js/content-sort.js" defer></script> <script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -9,8 +9,8 @@
Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a> Аккаунт администратора ещё не создан. <a th:href="@{/setup}">Создать первый аккаунт</a>
</div> </div>
<form class="card form-card form-stack" th:action="@{/login}" method="post"> <form class="card form-card form-stack" th:action="@{/login}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div> <div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required></div> <div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
<button class="btn btn-primary" type="submit">Войти</button> <button class="btn btn-primary" type="submit">Войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>

View File

@@ -7,9 +7,9 @@
<h1 class="page-title">Создание администратора</h1> <h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p> <p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<form class="card form-card form-stack" th:action="@{/setup}" method="post"> <form class="card form-card form-stack" th:action="@{/setup}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div> <div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" required></div> <div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" autocomplete="new-password" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button> <button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>

View File

@@ -33,7 +33,7 @@
</h3> </h3>
<p class="text-sm" th:text="${c.title}">—</p> <p class="text-sm" th:text="${c.title}">—</p>
<div class="flex flex-wrap gap-2 mt-2 text-sm"> <div class="flex flex-wrap gap-2 mt-2 text-sm">
<span th:if="${c.variantNumber}" class="badge badge-warn">вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span> <span th:if="${c.variantNumber}" class="badge">вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}">1</span>)</span> <span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}">1</span>)</span>
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span> <span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span> <span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>

View File

@@ -5,13 +5,12 @@
<div id="content"> <div id="content">
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a> <a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2"> <div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2">
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1> <h1 class="page-title mb-1" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div> </div>
<p class="text-sm text-muted mb-6"> <p class="text-muted mb-6">
<span th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</span> <span th:if="${deadlineMsk}"><b>Срок сдачи кода:</b> <span th:text="${deadlineMsk}"></span> МСК</span>
<span th:if="${deadlineMsk != null and defenseMsk != null}" class="nav-sep">·</span> <span th:if="${deadlineMsk != null and defenseMsk != null}" class="nav-sep">·</span>
<span th:if="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span> <span th:if="${defenseMsk}"><b>Защита:</b> <span th:text="${deadlineMsk}"></span> МСК</span>
</p> </p>
<h2 class="section-title">Условие лабораторной</h2> <h2 class="section-title">Условие лабораторной</h2>
@@ -28,7 +27,7 @@
<h2 class="section-title">Сдача работы</h2> <h2 class="section-title">Сдача работы</h2>
<div class="card form-card form-stack mb-4"> <div class="card form-card form-stack mb-4">
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт (истёк срок сдачи или уже назначено индивидуальное задание на защиту). Вы по-прежнему можете скачать отправленные работы ниже.</p> <p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт. Вы по-прежнему можете скачать отправленные работы ниже.</p>
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack"> <form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p> <p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
@@ -41,13 +40,13 @@
<h3 class="group-title">История сдач</h3> <h3 class="group-title">История сдач</h3>
<div class="card card-flush mb-6"> <div class="card card-flush mb-6">
<table> <table>
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead> <thead><tr><th>Попытка</th><th>Время (МСК)</th><th>Файлы</th><th></th></tr></thead>
<tbody> <tbody>
<tr th:each="s : ${submissions}"> <tr th:each="s : ${submissions}">
<td th:text="${s.seq}">1</td> <td th:text="${s.seq}">1</td>
<td class="text-muted" th:text="${s.createdAt}"></td> <td class="text-muted" th:text="${s.createdAt}"></td>
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td> <td class="font-mono text-sm" th:text="${#strings.listJoin(s.files, ', ')}">main.c</td>
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td> <td><a class="btn btn-secondary" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr> <tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr>
</tbody> </tbody>
@@ -57,7 +56,7 @@
<th:block th:if="${canSeeGrade}"> <th:block th:if="${canSeeGrade}">
<h2 class="section-title">Оценка и комментарии</h2> <h2 class="section-title">Оценка и комментарии</h2>
<div class="card card-body mb-6"> <div class="card card-body mb-6">
<p class="mb-4">Ваша оценка за лабораторную: <span class="badge badge-ok font-mono" th:text="${grade}"></span></p> <p class="mb-4 mt-1">Ваша оценка за лабораторную: <span class="score-big" th:text="${grade}"></span></p>
<div class="review-cols"> <div class="review-cols">
<div class="review-col" th:if="${!#lists.isEmpty(commentsPositive)}"> <div class="review-col" th:if="${!#lists.isEmpty(commentsPositive)}">
<h3 class="group-title tone-positive-text">Позитивный</h3> <h3 class="group-title tone-positive-text">Позитивный</h3>
@@ -89,25 +88,26 @@
<form class="inline-form" th:action="@{/student/defenses/start}" method="post"> <form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}"> <input type="hidden" name="labId" th:value="${lab.id}">
<input class="input input-sm" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}" required> <input class="input w-260" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}"
<button class="btn btn-primary" type="submit">Приступить к защите</button> inputmode="numeric" autocomplete="one-time-code" required>
<button class="btn btn-primary" type="submit">Приступить</button>
</form> </form>
</th:block> </th:block>
<div th:if="${defense != null and defense.status == 'started'}"> <div th:if="${defense != null and defense.status == 'started'}">
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}"></p> <p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3> <h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div> <div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post"> <form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button> <button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form> </form>
</div> </div>
<div th:if="${defense != null and defense.status == 'ready'}"> <div th:if="${defense != null and defense.status == 'ready'}">
<p class="badge badge-ok">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p> <p><span class="badge" th:classappend="${defense.readyReason == 'timeout'} ? 'badge-warn' : 'badge-ok'" th:text="${defense.readyReason == 'timeout' ? 'Вышло время' : 'Готов к сдаче'}"></span></p>
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p> <p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
</div> </div>
</div> </div>
<script src="/js/defense-timer.js"></script> <script th:src="@{/js/defense-timer.js(v='10')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -11,7 +11,7 @@
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}. ${c.title}|">ЛР</a> <a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}. ${c.title}|">ЛР</a>
</h2> </h2>
<div class="flex flex-wrap gap-2 text-sm mt-2"> <div class="flex flex-wrap gap-2 text-sm mt-2">
<span th:if="${c.variantNumber}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span> <span th:if="${c.variantNumber}" class="badge">Вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}"></span>)</span> <span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}"></span>)</span>
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span> <span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span> <span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>