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.springBootAutoconfigure)
// spring-tx нужен для обработчика DataIntegrityViolationException в LmsExceptionHandler.
api("org.springframework:spring-tx")
// Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию.
api(libs.jacksonModuleKotlin)
}

View File

@@ -37,3 +37,8 @@ class UnauthorizedException(
message: String,
code: String? = null,
) : 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
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
@RestControllerAdvice
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.common.BadRequestException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.zip.ZipInputStream
@@ -233,16 +234,45 @@ class ContentImporter(
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
entry = zis.nextEntry
while (true) {
val entry = zis.nextEntry ?: break
if (entry.isDirectory) continue
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
}
/** Читает запись архива с ограничением размера — защита от 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> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
@@ -285,4 +315,10 @@ class ContentImporter(
val seenSections: 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
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
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 java.util.UUID
/** Реордер материалов: меняет публичный порядок глав/разделов/статей — только админ. */
@RestController
@RequestMapping("/api/v1")
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentOrderController(
private val order: ContentOrderService,
) {

View File

@@ -60,12 +60,19 @@ class ContentPublicController(
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity
.ok()
.contentType(media)
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
val builder =
ResponseEntity
.ok()
.contentType(media)
.cacheControl(
org.springframework.http.CacheControl
.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 ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream
/**
@@ -55,16 +56,45 @@ class LabImportParser {
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>()
var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
entry = zis.nextEntry
while (true) {
val entry = zis.nextEntry ?: break
if (entry.isDirectory) continue
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
}
/** Читает запись архива с ограничением размера — защита от 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 {
val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
@@ -146,4 +176,10 @@ class LabImportParser {
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
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> {
val path = exchange.request.path.value()
if (isPublic(path)) {
return chain.filter(exchange)
// Клиентские X-Account-* не должны доходить до сервисов: эти заголовки ставит только gateway.
val stripped = stripAccountHeaders(exchange)
return chain.filter(stripped)
}
val token =
bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
return try {
val jwt = jwtDecoder.decode(token)
val stripped = stripAccountHeaders(exchange)
val request =
exchange.request
stripped.request
.mutate()
.header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
@@ -48,6 +51,17 @@ class JwtAuthenticationFilter(
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 =
path.startsWith("/actuator") ||
path == "/api/v1/auth/login" ||

View File

@@ -20,7 +20,7 @@ spring:
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
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:
endpoints:

View File

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

View File

@@ -67,6 +67,24 @@ data class StudentCandidate(
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(
val bytes: ByteArray,
val filename: String,

View File

@@ -1,9 +1,14 @@
package ru.ysu.lms.identity.roster
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.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@@ -17,4 +22,17 @@ class StudentController(
fun resetPassword(
@PathVariable id: UUID,
): 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.core.ParameterizedTypeReference
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import ru.ysu.lms.common.NotFoundException
import java.net.http.HttpClient
import java.time.Duration
@Service
class AcademicClient(
@Value("\${lms.academic.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
private val client = restClient(baseUrl)
fun getLab(
labId: String,
@@ -41,7 +44,7 @@ class AcademicClient(
class IdentityClient(
@Value("\${lms.identity.base-url}") baseUrl: String,
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
private val client = restClient(baseUrl)
fun getGroup(
groupId: String,
@@ -65,3 +68,20 @@ class IdentityClient(
.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")
fun issueCode(
@PathVariable id: UUID,

View File

@@ -30,6 +30,17 @@ class DefenseMyController(
@AuthenticationPrincipal jwt: Jwt,
): 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 =
if (d.status == DefenseStatus.waiting.name) d.copy(code = null, assignmentMd = "") else d

View File

@@ -102,12 +102,21 @@ class DefenseService(
sessions.save(s)
}
@Transactional(readOnly = true)
/** Не readOnly: внутри переносим истёкшие защиты в ready — изменения должны сохраниться. */
@Transactional
fun listForLab(labId: UUID): List<DefenseDto> {
syncTimeouts()
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
fun startByCode(
studentId: UUID,

View File

@@ -180,3 +180,18 @@ data class PublicationSetRequest(
val labId: String,
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
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
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.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.io.IOException
import java.nio.file.Files
import java.time.Instant
import java.util.UUID
@@ -28,8 +30,22 @@ class SubmissionService(
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
// Сначала запись в БД: unique-констрейнт (student, lab, 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)
}

View File

@@ -37,12 +37,32 @@ class SubmissionStorage(
val names = mutableListOf<String>()
for ((rawName, bytes) in files) {
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)
}
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 из сохранённых файлов сдачи, сохраняя реальное время изменения файлов. */
fun zipOf(storedDir: Path): ByteArray {
val out = ByteArrayOutputStream()
@@ -78,6 +98,22 @@ class SubmissionStorage(
seq: Int,
): 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 {
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }
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> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByLabIdIn(labIds: Collection<UUID>): List<DefenseSession>
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,

View File

@@ -21,4 +21,6 @@ interface GradePublicationStudentRepository : JpaRepository<GradePublicationStud
labId: UUID,
studentId: UUID,
): 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.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.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission
@@ -27,7 +28,7 @@ class SubmissionServiceTest {
var saved: Submission? = null
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
every { repo.save(any()) } answers {
every { repo.saveAndFlush(any()) } answers {
firstArg<Submission>().also { saved = it }
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
@@ -73,6 +74,17 @@ class SubmissionServiceTest {
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> {
val result = linkedMapOf<String, ByteArray>()
ZipInputStream(bytes.inputStream()).use { zin ->

View File

@@ -2,17 +2,20 @@ package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
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.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.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile
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.assignDefense
import ru.ysu.lms.web.api.defenseWatch
import ru.ysu.lms.web.api.issueDefenseCode
import ru.ysu.lms.web.api.resetDefense
import tools.jackson.databind.ObjectMapper
@@ -24,6 +27,37 @@ class AdminDefenseController(
markdown: MarkdownRenderer,
mapper: ObjectMapper,
) : 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")
fun assignDefense(
@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.downloadSubmissionAsAdmin
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.groupDetail
import ru.ysu.lms.web.api.groupLabData
import ru.ysu.lms.web.api.importGroup
import ru.ysu.lms.web.api.labDefenses
import ru.ysu.lms.web.api.labDetail
import ru.ysu.lms.web.api.labSubmissions
import ru.ysu.lms.web.api.listGrades
@@ -66,7 +67,6 @@ class AdminGroupController(
val grades = api.listGrades(t)
model.addAttribute("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount })
model.addAttribute("groupCount", groups.size)
model.addAttribute("labCount", labs.size)
val gradedByStudent = grades.groupBy { it.studentId }
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 memberIds = group.students.map { it.id }
val memberIdSet = memberIds.toSet()
// Данные всех лаб одним запросом вместо 4 вызовов на каждую лабу
val dataByLab = api.groupLabData(t, id, labs.map { it.id }).labs.associateBy { it.labId }
val publishedByLab =
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()
}
val labPublication =
@@ -162,9 +164,9 @@ class AdminGroupController(
total = memberIds.size,
)
}
val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } }
val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } }
val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } }
val assignmentsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).assignments.associateBy { it.studentId } }
val submissionsBy = labs.associateWith { lab -> dataByLab.getValue(lab.id).submissions.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)
@@ -204,6 +206,7 @@ class AdminGroupController(
defenseStatus = def?.status,
defenseCode = def?.code,
defenseSecondsLeft = def?.secondsLeft,
defenseReadyReason = def?.readyReason,
defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
defenseSessionId = def?.id,
defenseAssignmentMd = def?.assignmentMd,
@@ -278,8 +281,60 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication)
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")
fun setGrade(
@PathVariable id: String,
@@ -403,7 +458,14 @@ class AdminGroupController(
if (published) current.add(studentId) else current.remove(studentId)
val target = current.filter { it in memberIdSet }
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) {
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.AssignVariantPayload
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.LevelAssignPayload
import ru.ysu.lms.web.api.LevelAssignmentResultDto
import ru.ysu.lms.web.api.LevelPayload
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.addLevel
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.deleteVariant
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.labDetail
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.renameLevel
import ru.ysu.lms.web.api.saveVariant
@@ -94,18 +86,25 @@ class AdminLabController(
@PostMapping("/labs/{id}/toggle-status")
fun toggleStatus(
@PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String =
try {
api.toggleLabStatus(token(session), id)
redirect.addFlashAttribute("notice", "Статус лабораторной изменён")
"redirect:/admin/labs/$id"
"redirect:" + safeBack(back, "/admin/labs/$id")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"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")
fun deleteLab(
@PathVariable id: String,
@@ -129,23 +128,13 @@ class AdminLabController(
): String {
val t = token(session)
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 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>()
assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
val allVariants = lab.levels.flatMap { it.variants }
val totalVariants = allVariants.size
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("descriptionHtml", markdown.render(lab.descriptionMd))
@@ -159,6 +148,13 @@ class AdminLabController(
ru.ysu.lms.web.DateUtils
.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(
"deadlineLocal",
ru.ysu.lms.web.DateUtils
@@ -169,57 +165,9 @@ class AdminLabController(
ru.ysu.lms.web.DateUtils
.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"
}
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")
fun assignVariant(
@PathVariable labId: String,
@@ -320,24 +268,23 @@ class AdminLabController(
"redirect:/admin/labs/$labId"
}
@GetMapping("/labs/{id}/edit")
/** Быстрые действия «удалить уровень/вариант» на форме лабы; null — обычное сохранение. */
private fun handleEditDeleteAction(
token: String,
id: String,
action: String,
action: String?,
redirect: RedirectAttributes,
): String? =
when {
action.startsWith("delete-level-") -> {
api.deleteLevel(token, id, action.removePrefix("delete-level-"))
action.orEmpty().startsWith("delete-level-") -> {
api.deleteLevel(token, id, action.orEmpty().removePrefix("delete-level-"))
redirect.addFlashAttribute("notice", "Уровень удалён")
"redirect:/admin/labs/$id/edit"
"redirect:/admin/labs/$id"
}
action.startsWith("delete-variant-") -> {
api.deleteVariant(token, id, action.removePrefix("delete-variant-"))
action.orEmpty().startsWith("delete-variant-") -> {
api.deleteVariant(token, id, action.orEmpty().removePrefix("delete-variant-"))
redirect.addFlashAttribute("notice", "Вариант удалён")
"redirect:/admin/labs/$id/edit"
"redirect:/admin/labs/$id"
}
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")
fun editLab(
@@ -462,10 +388,10 @@ class AdminLabController(
}
else -> redirect.addFlashAttribute("notice", "Все изменения сохранены")
}
"redirect:/admin/labs/$id/edit"
"redirect:/admin/labs/$id"
} catch (ex: ApiException) {
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
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(
val studentId: String,
val fullName: String,
@@ -58,6 +32,7 @@ data class GroupLabCell(
val defenseStatus: String? = null,
val defenseCode: String? = null,
val defenseSecondsLeft: Long? = null,
val defenseReadyReason: String? = null,
val defenseAssigned: Boolean = false,
val defenseSessionId: 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.credentials
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.unenrollStudent
import tools.jackson.databind.ObjectMapper
@@ -42,13 +43,25 @@ class AdminStudentController(
try {
val result = api.resetPassword(token(session), studentId)
redirect.addFlashAttribute("resetPassword", result.password)
redirect.addFlashAttribute("resetFor", studentId)
// В тосте должно быть видно, ЧЬЙ пароль: за сеанс можно сбросить несколько подряд.
redirect.addFlashAttribute("resetFor", studentName(token(session), id, studentId))
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"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")
fun credentials(
@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.http.HttpHeaders
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import java.net.URLDecoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
@Component
class ApiClient(
@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"

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(
token: String,
studentId: String,
@@ -88,6 +104,20 @@ fun ApiClient.myDefense(
.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(
token: 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)
}
/** Поиск пользователей (студентов) для страницы управления. */
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(
token: String,
groupId: String,

View File

@@ -271,3 +271,16 @@ fun ApiClient.downloadSubmissionAsAdmin(
}
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,
)
@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)
data class ResetPasswordDto(
val password: String = "",
@@ -453,3 +477,19 @@ data class DefenseDto(
) {
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
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.ControllerAdvice
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.WebMvcConfigurer
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.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
class WebConfig(
private val authInterceptor: AuthInterceptor,
@@ -21,6 +35,10 @@ class WebConfig(
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry
.addInterceptor(NoCachePageInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/js/**", "/favicon.svg", "/error")
registry
.addInterceptor(mustChangePasswordInterceptor)
.addPathPatterns("/**")

View File

@@ -24,9 +24,15 @@ class ContentAssetProxy(
val media =
runCatching { MediaType.parseMediaType(asset.contentType) }
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity
.ok()
.contentType(media)
.body(asset.bytes)
val builder =
ResponseEntity
.ok()
.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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
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.meAssignments
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.myGroup
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")
fun download(
@PathVariable id: String,

View File

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

View File

@@ -7,44 +7,62 @@ body {
flex-direction: column;
background: var(--c-bg);
color: var(--c-text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
/* Палитра Б3: нейтральный каркас + бордовый акцент.
Цвет несёт смысл: акцент — действия и фокус, зелёный/янтарный — статусы защиты,
винный — деструктивные действия и негативная тональность. */
:root {
--c-bg: #f8fafc;
--c-bg: #fafafa;
--c-surface: #ffffff;
--c-text: #0f172a;
--c-text-muted: #64748b;
--c-text-link: #6366f1;
--c-text-link-hover: #4f46e5;
--c-border: #e2e8f0;
--c-accent: #6366f1;
--c-badge-ok-bg: #dcfce7;
--c-badge-ok-text: #166534;
--c-badge-warn-bg: #fef3c7;
--c-badge-warn-text: #92400e;
--c-tone-pos: #16803c;
--c-tone-neg: #b3283b;
--c-tone-neu: #64748b;
--c-text: #131316;
--c-text-muted: #74747c;
--c-text-link: #131316;
--c-text-link-hover: #7a2333;
--c-border: #e6e6e9;
--c-border-strong: #d4d4da;
--c-accent: #7a2333;
--c-accent-hover: #5f1b28;
--c-accent-soft: #f7edee;
--c-badge-ok-bg: #f2f7f3;
--c-badge-ok-text: #2f6b3a;
--c-badge-ok-border: #b7cdbd;
--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"] {
--c-bg: #0f1115;
--c-surface: #171a21;
--c-text: #e7e9ee;
--c-text-muted: #9aa3b2;
--c-text-link: #6ea8fe;
--c-text-link-hover: #8bb9fe;
--c-border: #262b36;
--c-accent: #6ea8fe;
--c-badge-ok-bg: rgba(16, 185, 129, 0.15);
--c-badge-ok-text: #6ee7b7;
--c-badge-warn-bg: rgba(251, 191, 36, 0.15);
--c-badge-warn-text: #fcd34d;
--c-tone-pos: #4ade80;
--c-tone-neg: #f87171;
--c-tone-neu: #9aa3b2;
--c-bg: #101014;
--c-surface: #17181d;
--c-text: #e8e9ec;
--c-text-muted: #9a9ba4;
--c-text-link: #e8e9ec;
--c-text-link-hover: #d98b98;
--c-border: #26272e;
--c-border-strong: #34353d;
--c-accent: #a64d5f;
--c-accent-hover: #b86070;
--c-accent-soft: rgba(166, 77, 95, 0.16);
--c-badge-ok-bg: rgba(46, 107, 58, 0.25);
--c-badge-ok-text: #8fd4a1;
--c-badge-ok-border: rgba(143, 212, 161, 0.4);
--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; }
@@ -56,59 +74,74 @@ a:not(.btn):hover { color: var(--c-text-link-hover); }
/* Layout */
.page { display: flex; flex-direction: column; min-height: 100vh; }
.page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); }
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; 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) {
.page-header-inner { padding: 0.5rem 0.75rem; }
.nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; }
.nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
.page-header-inner { padding: 0.5rem 0.875rem; gap: 0.5rem 1rem; }
.nav { gap: 0.5rem 0.75rem; flex-wrap: wrap; }
.user { gap: 0.5rem; }
.nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.page-main { flex: 1; width: 100%; max-width: 1152px; margin: 0 auto; padding: 2rem 1.5rem; }
.page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); }
.page-main { flex: 1; width: 100%; max-width: 1200px; margin: 0 auto; padding: 2.5rem 1.75rem 3.5rem; }
@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 { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; }
.nav-link { color: var(--c-text-link); text-decoration: none; }
.nav-link:hover { color: var(--c-text-link-hover); }
.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-muted); text-decoration: none; font-weight: 500; }
.nav-link:hover { color: var(--c-text); }
.nav-link.active { color: var(--c-accent); }
.nav-sep { color: var(--c-text-muted); }
.logo { font-size: 1.125rem; font-weight: 600; color: var(--c-text); text-decoration: none; }
.nav-user { font-weight: 500; }
.logo { font-size: 1rem; font-weight: 700; color: var(--c-text); text-decoration: none; letter-spacing: -0.01em; }
.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-icon { width: 1.05rem; height: 1.05rem; display: block; }
.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.breadcrumb .nav-sep { user-select: none; }
.check-list { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; max-height: 12rem; overflow-y: auto; }
/* Buttons */
/* Buttons — по спецификации Б3: основная кнопка залита акцентом,
вторичная и опасная — контурные. Компактный кегль 13px. */
.btn {
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: none; cursor: pointer; transition: opacity 0.15s;
border: 1px solid var(--c-accent); border-radius: var(--radius);
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-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-primary { background: var(--c-accent); color: white; text-decoration: none; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
background: var(--c-surface); color: var(--c-text); text-decoration: none;
box-shadow: inset 0 0 0 1px var(--c-border);
}
.btn-secondary:hover { background: var(--c-border); }
.btn:active { opacity: 0.85; }
.btn:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.btn:hover { background: var(--c-accent-hover); border-color: var(--c-accent-hover); }
.btn-primary { background: var(--c-accent); border-color: var(--c-accent); color: #ffffff; }
.btn-primary:hover { background: var(--c-accent-hover); border-color: var(--c-accent-hover); }
.btn-secondary { background: var(--c-surface); border-color: var(--c-border-strong); color: var(--c-text); }
.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-danger:hover { background: var(--c-accent-soft); border-color: var(--c-accent); }
/* Forms */
.input {
width: 100%; border-radius: 0.5rem; background: var(--c-bg); color: var(--c-text);
padding: 0.5rem 0.75rem; font-size: 0.875rem; border: 1px solid var(--c-border);
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-strong);
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); }
.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); }
/* Cards */
.card { border-radius: 0.75rem; 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 { border-radius: var(--radius-lg); background: var(--c-surface); border: 1px solid var(--c-border); }
.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; }
/* 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)); }
/* 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; }
/* Шапка страницы в стиле Б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-title { font-size: 0.875rem; color: var(--c-text-muted); margin: 0; }
.text-muted { color: var(--c-text-muted); }
.opacity-30 { opacity: 0.3; }
.text-center { text-align: center; }
.text-sm { font-size: 0.875rem; }
.font-mono { font-family: monospace; }
/* Spacing */
/* ---- Utilities: плоские классы поверх компонентов (в духе Bootstrap) ---- */
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.m-0 { margin: 0; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-auto { margin-left: auto; }
.p-0 { padding: 0; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.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-260 { width: 260px; }
.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 */
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 500; }
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); }
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); }
.badge-btn { border: none; cursor: pointer; padding: 0.125rem 0.625rem; }
/* Badges — «этикетки»: строгая рамка, разрядка капсом; только индикаторы статуса */
.badge {
display: inline-flex; align-items: center;
border-radius: var(--radius-xs); padding: 0.1rem 0.5rem;
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 */
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; }
/* 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-success { background: #dcfce7; color: #166534; }
.alert-warning { background: #fef3c7; color: #92400e; }
.ml-2 { margin-left: 0.5rem; }
.list-pad { padding-left: 1.25rem; }
.import-card { border-color: rgba(16,185,129,0.4); }
.mini-alert { padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 0.5rem; }
.font-semibold { font-weight: 600; }
.import-card { border-color: var(--c-badge-ok-border); }
.mini-alert { padding: 0.5rem 0.75rem; border-radius: var(--radius-xs); margin-bottom: 0.5rem; }
/* Theme toggle */
.theme-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* Group assignment page */
.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 .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; }
.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; }
.filter-select { width: auto; }
.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; }
/* Всплывающие уведомления (flash notice/error) вместо статичных алертов */
/* Уведомления: внизу по центру, в стиле Б3 (поверх .notice) */
.toast-host {
position: fixed; top: 1rem; right: 1rem; z-index: 100;
display: flex; flex-direction: column; gap: 0.5rem;
width: min(24rem, calc(100vw - 2rem)); pointer-events: none;
position: fixed; left: 50%; transform: translateX(-50%); bottom: 1.5rem; z-index: 100;
display: flex; flex-direction: column; gap: 0.5rem; align-items: stretch;
width: min(34rem, calc(100vw - 2rem)); pointer-events: none;
}
.toast-item {
pointer-events: auto; display: flex; align-items: flex-start; gap: 0.5rem;
padding: 0.7rem 0.85rem; border-radius: 0.5rem; border: 1px solid var(--c-border);
border-left-width: 4px; background: var(--c-surface); color: var(--c-text);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
pointer-events: auto; display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.75rem 1rem; border-radius: var(--radius); border: 1px solid var(--c-border);
border-left: 3px solid var(--c-accent); background: var(--c-surface); color: var(--c-text);
font-size: 0.8125rem;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
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-success { border-left-color: #16a34a; }
.toast-item.toast-error { border-left-color: #dc2626; }
.toast-item.toast-warning { border-left-color: #d97706; }
.toast-item .toast-text .code { margin: 0 0.1rem; }
.toast-item.toast-success { border-left-color: var(--c-tone-pos); background: var(--c-badge-ok-bg); }
.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 {
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); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(-6px); }
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.confirm-text { margin: 0; font-size: 0.875rem; white-space: pre-line; overflow-wrap: anywhere; }
/* Modals */
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);
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 */
html.modal-open body {
@@ -277,10 +339,13 @@ html.modal-open body {
.md-body h2 { font-size: 1.125rem; }
.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 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 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 table { margin: 0; }
/* Таблицы в статьях прокручиваются по горизонтали, как блоки кода */
@@ -293,7 +358,7 @@ html.modal-open body {
font: inherit; font-size: 0.72rem; line-height: 1;
padding: 0.25rem 0.5rem; cursor: pointer;
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.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; }
/* 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 */
.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 */
.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-pos { background: #16803c; color: #fff; }
.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: #2e6b3a; 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-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;
max-height: 14rem; overflow-y: auto;
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-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-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ac-count { flex: 0 0 auto; background: var(--c-border); color: var(--c-text-muted); }
.ac-del { flex: 0 0 auto; border: none; background: transparent; color: var(--c-text-muted); cursor: pointer; padding: 0 0.25rem; border-radius: 0.25rem; font-size: 0.75rem; line-height: 1.4; }
.ac-del { 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:disabled { opacity: 0.3; cursor: not-allowed; }
.ac-empty { padding: 0.4rem 0.6rem; font-size: 0.875rem; color: var(--c-text-muted); }
/* Ячейка матрицы: вариант и кнопки действий под ним; кнопки в шапке ЛР */
.cell-stack { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; }
/* Ячейка матрицы в стиле Б3: вариант и оценка — текстом, подстрока — сдача и защита с точкой */
.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 {
display: inline-block; padding: 0.18rem 0.5rem; border-radius: 0.4rem;
border: 1px solid var(--c-border); background: var(--c-bg);
font-size: 0.8rem; white-space: nowrap; cursor: pointer; color: var(--c-text);
display: inline-block; padding: 0; border: none; background: none;
font-size: 0.84375rem; font-weight: 600; white-space: nowrap; cursor: pointer;
color: var(--c-text); text-decoration: none;
}
.variant-btn:hover { border-color: var(--c-accent); color: var(--c-accent); }
.pub-toggle { width: 1.05rem; height: 1.05rem; margin: 0; cursor: pointer; accent-color: #16a34a; }
.variant-btn.manual { border-color: var(--c-border-strong, var(--c-border)); font-weight: 600; }
.cell-btns { display: flex; gap: 0.2rem; }
.cell-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.15rem;
min-width: 1.6rem; height: 1.6rem; padding: 0 0.35rem; cursor: pointer;
border: 1px solid var(--c-border); border-radius: 0.35rem;
background: var(--c-surface); color: var(--c-text); text-decoration: none; font-size: 0.85rem;
.variant-btn:hover { color: var(--c-accent); text-decoration: underline; }
.variant-btn.manual { font-weight: 700; }
.variant-btn .cell-none { color: var(--c-text-muted); font-weight: 400; }
.variant-btn.manual .cell-none { font-weight: 600; }
.score-link {
display: inline-block; padding: 0; border: none; background: none;
font-size: 0.8125rem; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--c-text); cursor: pointer;
}
.cell-btn:hover { background: var(--c-bg); }
.cell-btn.dim { color: var(--c-text-muted); }
.cell-btn.warn { color: #d97706; }
.cell-btn.ok { color: #16a34a; }
.cell-num { font-size: 0.72rem; opacity: 0.75; }
.score-link:hover { color: var(--c-accent); text-decoration: underline; }
.score-link.none { color: var(--c-text-muted); font-weight: 400; }
.cell-sub { display: flex; align-items: center; gap: 0.875rem; flex-wrap: wrap; font-size: 0.75rem; }
.sub-link { color: var(--c-text-muted); text-decoration: none; font-variant-numeric: tabular-nums; }
.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); }
.defense-cell-timer { font-variant-numeric: tabular-nums; }
.lab-head-actions { display: flex; gap: 0.2rem; }
.mini-btn {
display: inline-flex; align-items: center; justify-content: center;
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;
}
.mini-btn:hover { background: var(--c-bg); }
.mini-btn.ok { color: #16a34a; }
.mini-btn.warn { color: #d97706; }
.mini-btn.partial { color: #2563eb; }
.js-pub-eye .eye-closed { display: none; }
.js-pub-eye.is-empty .eye-open { display: none; }
.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 .modal-body { max-height: 88vh; min-height: 62vh; }
.mini-btn svg, .cell-btn svg { width: 1rem; height: 1rem; }
.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; }
.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: #2e6b3a; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; }
.chip-neg { background: #8f2d3c; color: #fff; }
/* 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-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;
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; }
/* ---- Матрица группы и карточки защиты ---- */
.m-0 { margin: 0; }
.mb-8 { margin-bottom: 2rem; }
.mb-0 { margin-bottom: 0; }
.text-strong { font-weight: 600; color: var(--c-text); }
.text-right { text-align: right; }
.font-medium { font-weight: 500; }
.scroll-x { overflow-x: auto; }
.matrix { border-collapse: separate; border-spacing: 0; }
.matrix th { vertical-align: top; }
.matrix td { vertical-align: middle; }
.matrix th {
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 td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix td.student-cell { min-width: 12rem; }
.lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
.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-label { color: var(--c-text-muted); min-width: 3.5rem; }
.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; }
/* Редактируемая матрица: закреплённый столбец и компактные селекты */
.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; }
.level-form { margin: 0; }
.level-select {
@@ -474,7 +578,7 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.cell-chip {
display: inline-flex; align-items: center; gap: 0.15rem;
font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem;
border: 1px solid var(--c-border); border-radius: 99px;
border: 1px solid var(--c-border); border-radius: var(--radius-sm);
background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer;
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-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; }
.chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; }
.chip-link:hover { color: var(--c-text-link); }
.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-ok { color: #86efac; background: rgba(22, 101, 52, 0.25); border-color: rgba(22, 101, 52, 0.5); }
.lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; }
.actions-cell { white-space: nowrap; }
.actions-cell .inline-form { display: inline-block; }
/* Действия столбиком: ровные одинаковые кнопки друг под другом */
.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: сортируемые таблицы и поиск */
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-control {
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;
}
th.th-filterable { padding-bottom: 0.35rem; }
@@ -509,14 +623,16 @@ th.th-filterable { padding-bottom: 0.35rem; }
.drag-handle {
display: inline-block; flex: 0 0 auto; width: 1.5rem; text-align: center;
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); }
.mini-btn.danger:hover { color: var(--c-accent); border-color: #dcb9be; background: var(--c-accent-soft); }
.drag-handle:active { cursor: grabbing; }
.dragging { opacity: 0.45; }
.drop-before { 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 {
@@ -524,7 +640,7 @@ th.th-filterable { padding-bottom: 0.35rem; }
display: flex; align-items: center; gap: 0.6rem;
max-width: min(24rem, calc(100vw - 2rem));
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;
}
@@ -555,13 +671,13 @@ th.th-filterable { padding-bottom: 0.35rem; }
.md-toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem;
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-btn {
display: inline-flex; align-items: center; justify-content: center;
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);
}
.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);
}
.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">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#6366f1"/>
<path d="M9 8v13h5l-1.5 3H6V8z" fill="#eef2ff"/>
<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"/>
<rect x="1" y="1" width="30" height="30" rx="8" fill="#7a2333"/>
<path d="M11.5 7.5h4v13.2h8.2v3.8h-12.2z" fill="#fdfcf9"/>
</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');
rm.type = 'button';
rm.className = 'btn btn-secondary btn-sm';
rm.className = 'btn btn-secondary';
rm.textContent = 'удалить';
rm.onclick = function () { row.remove(); };
@@ -260,11 +260,12 @@ function attachPublishToggles() {
if (!urlEl) return;
var csrfEl = document.querySelector('input[name="_csrf"]');
document.querySelectorAll('.pub-toggle').forEach(function (cb) {
var labId = cb.getAttribute('data-lab') || '';
cb.addEventListener('change', function () {
var desired = cb.checked;
var body = new URLSearchParams();
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('published', desired ? 'true' : 'false');
cb.disabled = true;
@@ -277,9 +278,14 @@ function attachPublishToggles() {
.then(function (res) {
cb.disabled = false;
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 {
cb.checked = !desired;
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') {
return code
? 'Задание назначено, код выдан — ждём входа студента.'
? 'Задание назначено. Код входа: ' + code + ' — ждём входа студента.'
: 'Задание назначено, код ещё не выдан.';
}
if (status === 'started') return 'Студент выполняет работу, осталось ' + fmtSeconds(seconds) + '.';
if (status === 'ready') return 'Студент готов к сдаче. Можно начать заново для дополнительного задания.';
if (status === 'ready') {
return defenseReasonText(reason) + '. Можно начать заново для дополнительного задания.';
}
return '';
}
/** Открывает диалог задания; кнопка может не нести data-* (тогда это «назначить новое»). */
var DEFENSE_OPEN_SESSION = '';
function openDefense(btn) {
var form = $id('defense-form');
if (!form) return;
@@ -321,15 +474,17 @@ function openDefense(btn) {
var status = btn.getAttribute('data-status') || '';
var code = btn.getAttribute('data-code') || '';
var seconds = parseInt(btn.getAttribute('data-seconds') || '0', 10);
var reason = btn.getAttribute('data-reason') || '';
var actions = $id('defense-actions');
var statusEl = $id('defense-status');
DEFENSE_OPEN_SESSION = session;
if (session) {
$id('defense-issue-form').action = '/admin/defenses/' + session + '/issue-code';
$id('defense-reset-form').action = '/admin/defenses/' + session + '/reset';
$id('defense-action-lab').value = lab;
$id('defense-reset-lab').value = lab;
$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;
} else {
if (statusEl) statusEl.textContent = '';
@@ -361,7 +516,14 @@ function openVariant(btn) {
levelSel.innerHTML = '';
VARIANT_CACHE = {};
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) {
(data.levels || []).forEach(function (lvl, i) {
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() {
@@ -448,7 +596,7 @@ function focusLab() {
if (!s) return;
var labId = s.value;
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 () {
focusLab();
tickDefenseCells();
startDefenseWatch();
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 collectPicked(form) {
var picked = Array.prototype.slice.call(document.querySelectorAll('.pick-student:checked'));
if (!picked.length) {
alert('Отметьте хотя бы одного студента.');
return false;
}
picked.forEach(function (c) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'studentIds';
input.value = c.value;
form.appendChild(input);
});
return true;
}
function confirmOccupied(sel) {
var opt = sel.options[sel.selectedIndex];
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) {
if (sel.dataset.prev !== undefined) sel.value = sel.dataset.prev;
return;
/* Страница лабораторной: переключение просмотра/редактирования параметров. */
(function () {
function setMode(editing) {
var view = document.getElementById('lab-description-view');
var form = document.getElementById('lab-edit-form');
if (!view || !form) return;
view.hidden = editing;
form.hidden = !editing;
if (editing) {
var t = form.querySelector('input[name="title"]');
if (t) t.focus();
}
}
sel.dataset.prev = sel.value;
if (sel.form) sel.form.submit();
}
window.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.pick-variant').forEach(function (s) { s.dataset.prev = s.value; });
});
document.addEventListener('click', function (e) {
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' },
body: JSON.stringify(collectPayload())
}).then(function (response) {
// после истечения сессии сервер отвечает редиректом на /login: fetch вернёт 200 с HTML —
// считать такое успехом нельзя, иначе порядок «сохранится» только на экране
if (response.redirected) throw new Error('сессия истекла — обновите страницу');
if (!response.ok) throw new Error('HTTP ' + response.status);
pending = false;
saving = false;

View File

@@ -1,15 +1,67 @@
/* Таймер защиты: локальный отщёлник + подстройка по серверу каждые ~10 секунд. */
document.querySelectorAll('.defense-timer').forEach(function (el) {
var labId = el.getAttribute('data-lab') || '';
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
var tick = null;
var ended = false;
function render(v) {
var m = Math.floor(v / 60), s = v % 60;
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
}
render(left);
var tick = setInterval(function () {
left -= 1;
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
function startTick() {
if (tick) clearInterval(tick);
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);
}, 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) {
try {

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -103,17 +103,25 @@
};
window.cancelArticleEdit = function () {
if (dirty && !confirm('Выйти из редактирования без сохранения?')) return;
var view = document.getElementById('article-view');
var form = document.getElementById('article-edit');
if (!view || !form) return;
form.hidden = true;
view.hidden = false;
dirty = false;
var proceed = function () {
var view = document.getElementById('article-view');
var form = document.getElementById('article-edit');
if (!view || !form) return;
form.hidden = true;
view.hidden = false;
dirty = false;
};
if (!dirty) { proceed(); return; }
window.showConfirm({
title: 'Несохранённые изменения',
text: 'Выйти из редактирования без сохранения?',
okText: 'Выйти'
}).then(function (ok) { if (ok) proceed(); });
};
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;
th.classList.add('th-sortable');
if (!th.title) th.title = 'Сортировать по этому столбцу';
th.addEventListener('click', function (event) {
if (event.target.closest('form, button, a, select, input')) return;
// сортировка с клавиатуры: фокус + Enter/пробел, состояние доступно скринридеру
th.tabIndex = 0;
if (th.classList.contains('is-asc')) th.setAttribute('aria-sort', 'ascending');
function doSort() {
var asc = !th.classList.contains('is-asc');
clearAllMarkers(table);
th.classList.add(asc ? 'is-asc' : 'is-desc');
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
sortColumn(table, index, asc ? 1 : -1);
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);

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 class="flex items-center justify-between mb-4">
<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 class="card card-flush">
<table>
@@ -36,7 +36,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<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 class="card card-flush">
<table>

View File

@@ -3,55 +3,67 @@
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body>
<div id="content">
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="page-head">
<div>
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
<p class="kicker">Ведомость группы</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 class="flex flex-wrap gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<div class="actions">
<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>
<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 class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Студенты и лабораторные</h2>
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
<input class="input input-sm table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
<div class="toolbar">
<input class="input table-search w-260" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input" id="lab-focus">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</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})}">
<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="scroll-x">
<table class="matrix tablekit" id="group-table">
<thead>
<tr>
<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">
<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">
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input type="hidden" name="published"
th:value="${pub != null and pub.allPublished} ? 'false' : 'true'">
<button class="mini-btn" type="submit"
th:classappend="${pub != null and pub.allPublished} ? 'ok' : (${pub != null and pub.publishedCount > 0} ? 'partial' : 'warn')"
th:title="${pub == null} ? '' : (${pub.allPublished} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pub.publishedCount > 0} ? 'Опубликовано ' + ${pub.publishedCount} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<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>
<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>
<span class="mini-count" th:if="${pub != null and !pub.allPublished and pub.publishedCount > 0}" th:text="${pub.publishedCount}"></span>
<input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'">
<button class="mini-btn js-pub-eye" type="submit"
th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
th:classappend="${all} ? 'is-all' : (${pc > 0} ? 'is-partial' : 'is-empty')"
th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<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>
<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>
</form>
<a class="mini-btn"
@@ -67,66 +79,67 @@
<tbody>
<tr th:each="row : ${studentGrades}">
<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>
</td>
<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 : 'без варианта'}">
<div class="cell-stack">
<div class="cell-top">
<div class="cell-main">
<button class="variant-btn" type="button"
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}"
onclick="openVariant(this)"
title="Назначить вариант вручную или выдать случайный">Вариант <b th:text="${cell.variantNumber} ?: '—'">—</b></button>
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"
title="Назначить вариант вручную или выдать случайный">
<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"
th:checked="${cell.published}"
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
</div>
<div class="cell-btns">
<a th:if="${cell.submissionId}" class="cell-btn" title="Скачать последнее решение"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">↓<span class="cell-num" th:text="${cell.submissionSeq}"></span></a>
<span th:unless="${cell.submissionId}" class="cell-btn dim" title="Решений пока не сдавалось">↓</span>
<button class="cell-btn" type="button"
<div class="cell-sub">
<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 th:text="${cell.submissionSeq}">1</span></a>
<span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
<button class="cell-btn st js-defense-cell" type="button"
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}"
onclick="openDefense(this)"
th:title="${cell.defenseStatus == null} ? 'Задание на защиту не назначено' : 'Защита: задание и действия'">
<span>🛡</span>
<span th:if="${cell.defenseStatus == null}">нет</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}">ожид.</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}">код</span>
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer" th:attr="data-seconds=${cell.defenseSecondsLeft}">идёт</span>
<span th:if="${cell.defenseStatus == 'ready'}">готов</span>
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"
th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
<span class="cell-dim">защита:</span>
<span class="js-def-label"
th:text="${cell.defenseStatus == null ? '—' : (cell.defenseStatus == 'waiting'
? (cell.defenseCode != null ? 'код ' + cell.defenseCode : 'ожидает')
: (cell.defenseStatus == 'ready' ? 'готова' : ''))}">—</span>
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer js-def-timer" th:data-seconds="${cell.defenseSecondsLeft}">идёт</span>
</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>
</td>
<td class="text-center">
<div class="cell-stack">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
</td>
<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}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</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>
</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 th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 3}">В группе нет студентов.</td></tr>
</tbody>
@@ -138,7 +151,7 @@
<dialog id="modal-grade" class="modal modal-lg">
<div class="modal-header">
<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 class="modal-body">
<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>
<div id="cs-rows"></div>
<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>
</div>
</form>
@@ -163,7 +176,7 @@
<dialog id="modal-variant" class="modal modal-lg">
<div class="modal-header">
<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 class="modal-body form-stack">
<p class="text-sm text-muted" id="variant-who">—</p>
@@ -199,7 +212,7 @@
<dialog id="modal-defense" class="modal">
<div class="modal-header">
<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 class="modal-body">
<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}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post"
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-reset-lab">
<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">
<div class="modal-header">
<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 class="modal-body">
<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">
<h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
@@ -324,7 +309,42 @@
</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>
</body>
</html>

View File

@@ -3,31 +3,19 @@
<head><title>Группы — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Группы</h1>
<div class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/groups}" method="post">
<h2 class="section-title">Создать группу</h2>
<div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<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 class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="page-title m-0">Группы</h1>
<div class="flex gap-2">
<input class="input table-search" data-target="groups-table"
placeholder="Поиск по названию…" aria-label="Поиск по группам">
<button class="btn btn-secondary" type="button" data-modal="modal-import-group">Импорт</button>
<button class="btn btn-primary" type="button" data-modal="modal-create-group">+ Новая группа</button>
</div>
</div>
<div class="card card-flush">
<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>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
@@ -38,6 +26,39 @@
</tbody>
</table>
</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>
</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>
<div id="content">
<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>
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">
Статус:
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
<div class="meta mb-2">
<span class="badge"
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}">
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status}">draft</button>
<button class="btn btn-sm" type="submit"
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать
</button>
</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 class="scroll-x">
<table class="tablekit" th:attr="id='assign-table-' + ${gStat.index}">
<thead><tr>
<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>
<th class="th-w-sm" data-filter>Источник</th><th data-filter>Сдача</th>
</tr></thead>
<tbody>
<tr th:each="s : ${g.students}">
<td><input type="checkbox" class="pick-student" th:value="${s.studentId}"></td>
<td>
<div th:text="${s.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${s.studentNo}">—</div>
</td>
<td>
<form class="inline-form" method="post"
th:action="@{/admin/labs/{id}/set-level(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${s.studentId}">
<input type="hidden" name="labId" th:value="${lab.id}">
<select name="labLevelId" class="input input-sm" onchange="this.form.submit()"
th:disabled="${#lists.isEmpty(lab.levels)}">
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
th:selected="${lv.id == s.levelId}" th:text="${lv.title}">Уровень</option>
</select>
</form>
</td>
<td>
<form class="inline-form" method="post"
th:action="@{/admin/labs/{id}/assign(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${s.studentId}">
<input type="hidden" name="labId" th:value="${lab.id}">
<select name="variantId" class="input input-sm pick-variant" onchange="confirmOccupied(this)">
<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 == 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>
<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="${totalVariants > 0}"> · Вариантов занято: <span th:text="${occupiedVariants}"></span> из <span
th:text="${totalVariants}"></span></span>
<span th:if="${submissionCount > 0}"> · Сдач: <span th:text="${submissionCount}"></span></span>
</div>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 class="section-title m-0">Условие лабораторной</h2>
<button class="mini-btn" type="button" data-action="lab-edit"
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>
</div>
<div class="card card-body md-body mb-6" id="lab-description-view" th:utext="${descriptionHtml}"></div>
<form id="lab-edit-form" hidden class="card card-body form-stack mb-6" method="post"
th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="grid grid-2">
<div><label class="label" for="l-title">Название</label>
<input class="input" id="l-title" name="title" th:value="${lab.title}" required></div>
<div><label class="label" for="l-points">Максимум баллов</label>
<input class="input" id="l-points" name="pointsMax" type="number" step="0.1" min="0"
th:value="${lab.pointsMax}" required></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="l-deadline">Срок сдачи (время — МСК)</label>
<input class="input" id="l-deadline" name="deadlineAt" type="datetime-local"
th:value="${deadlineLocal}"></div>
<div><label class="label" for="l-defense">Дата защиты (время — МСК)</label>
<input class="input" id="l-defense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}">
</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>
<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 class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-2">
<h3 class="section-title" th:text="${level.title}">Уровень</h3>
<span class="text-sm text-muted" th:text="|Вариантов: ${#lists.size(level.variants)}|">0</span>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h3 class="section-title m-0" th:text="${level.title}">Уровень</h3>
<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 class="scroll-x">
<table>
<thead><tr><th class="th-w-sm">№</th><th class="th-w-md">Название</th><th>Условие</th></tr></thead>
<tbody>
<tr th:each="v : ${level.variants}">
<td class="font-mono" th:text="${v.number}">1</td>
<td th:text="${v.title}">—</td>
<td class="text-muted preview" th:text="${#strings.abbreviate(v.statementMd, 70)}">—</td>
</tr>
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="3" class="empty">Вариантов нет.</td></tr>
</tbody>
</table>
<div th:each="v : ${level.variants}" class="variant-block mb-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="variant-name">Вариант <b th:text="${v.number}">—</b><span th:if="${v.title}"
class="text-muted"
th:text="' · ' + ${v.title}"></span>
</div>
<div class="flex gap-2">
<button class="mini-btn" type="button" th:attr="data-modal=|modal-variant-edit-${v.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}/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>
<p th:if="${#lists.isEmpty(level.variants)}" class="text-sm text-muted m-0">Вариантов пока нет.</p>
</div>
</div>
<dialog id="modal-distribute" class="modal">
<div class="modal-header">
<h2 class="modal-title">Распределение вариантов</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-distribute')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm">
Студенты выбранных групп раздаются одной общей очерёдностью. Каждый получает
вариант <b>со своего уровня</b> (без уровня — с первого уровня лабораторной):
пока внутри уровня есть не выданные никому варианты — они выдаются без повторов,
затем вариант берётся из наименее занятых. Ручные закрепления сохраняются всегда.
</p>
<div th:if="${!#lists.isEmpty(groups)}">
<label class="label">Группы (ничего не выбрано — все группы)</label>
<div class="check-list">
<label class="tone-label" th:each="g : ${groups}">
<input type="checkbox" name="groupIds" th:value="${g.id}">
<span th:text="${g.name}">—</span>
</label>
<div th:each="level : ${lab.levels}" th:remove="tag">
<dialog th:id="|modal-level-rename-${level.id}|" 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/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="levelId" th:value="${level.id}">
<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>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog th:id="|modal-variant-add-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title" th:text="|Новый вариант — ${level.title}|">Новый вариант</h2>
<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><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>
<label class="tone-label">
<input type="checkbox" name="force" value="true">
Пересоздать заново (сбросить авто-назначения у выбранных; вручную закреплённые останутся)
</label>
<button class="btn btn-primary" type="submit">Распределить</button>
</form>
</div>
</dialog>
<script src="/js/admin-lab.js" defer></script>
<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="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<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}"
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>
</body>
</html>

View File

@@ -6,34 +6,44 @@
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1>
<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="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
<button class="btn btn-secondary" type="button" data-modal="modal-import-lab">Импорт</button>
<button class="btn btn-primary" type="button" data-modal="modal-create-lab">+ Создать</button>
</div>
</div>
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
<div class="card card-flush">
<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>
<tr th:each="lab : ${labs}">
<td th:text="${lab.number}">1</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:attr="data-filter-value=${lab.status}">
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="badge badge-btn"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
type="submit" th:text="${lab.status}">—</button>
</form>
<td th:attr="data-filter-value=${lab.status == 'published' ? 'Опубликована' : (lab.status == 'archived' ? 'В архиве' : 'Черновик')}">
<span class="badge"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">—</span>
</td>
<td class="text-center">
<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 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 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>
</table>
</div>
@@ -42,7 +52,7 @@
<dialog id="modal-import-lab" class="modal">
<div class="modal-header">
<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 class="modal-body">
<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">
<div class="modal-header">
<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 class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
@@ -100,4 +110,4 @@
</dialog>
</div>
</body>
</html>
</html>

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>
<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="new">Новый пароль</label><input class="input" id="new" name="new" type="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="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" 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" autocomplete="new-password" required></div>
<button class="btn btn-primary" type="submit">Сменить пароль</button>
<a th:if="${currentUser != null and !currentUser.mustChangePassword}"
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>

View File

@@ -4,102 +4,49 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">LMS</title>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}">
<link rel="stylesheet" th:href="@{/css/app.css}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css}" disabled>
<script th:src="@{/js/htmx.min.js}" defer></script>
<script th:src="@{/js/highlight.min.js}" defer></script>
<script th:src="@{/js/table-scroll.js}" defer></script>
<script th:src="@{/js/tablekit.js}" defer></script>
<script th:src="@{/js/ui-state.js}" defer></script>
<script th:src="@{/js/md-content.js}" defer></script>
<script>
(function(){
var t = localStorage.getItem('lms-theme') || 'light';
document.documentElement.setAttribute('data-theme', 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); });
};
})();
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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Inter с запасным системным гротеском. Загрузка не блокирует рендер:
при недоступном CDN страница открывается на системном шрифте. -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet" media="print" onload="this.media='all'">
<noscript>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</noscript>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg(v='2')}">
<!-- v=4: сброс кэша стилей после редизайна; при изменении css поднимать версию -->
<link rel="stylesheet" th:href="@{/css/app.css(v='10')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='10')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='10')}" disabled>
<script th:src="@{/js/highlight.min.js(v='10')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='10')}" defer></script>
<script th:src="@{/js/tablekit.js(v='10')}" defer></script>
<script th:src="@{/js/theme-init.js(v='10')}"></script>
<script th:src="@{/js/app.js(v='10')}" defer></script>
<script th:src="@{/js/ui-state.js(v='10')}" defer></script>
<script th:src="@{/js/md-content.js(v='10')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='10')}" defer></script>
</head>
<body>
<div class="page">
<header class="page-header">
<div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS</a>
<nav class="nav">
<a th:href="@{/}" class="logo">LMS<b>.</b></a>
<nav class="nav" id="top-nav">
<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/users}" 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: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>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary btn-sm">Войти</a>
<button type="button" onclick="toggleTheme()" class="btn btn-secondary btn-sm theme-btn"
</nav>
<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="Сменить тему">
<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"/>
@@ -116,47 +63,50 @@
</g>
</svg>
</button>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn">Войти</a>
<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}">
</form>
</nav>
</div>
</div>
</header>
<main class="page-main">
<th:block th:replace="${content}" />
</main>
<footer class="page-footer">
LMS v0.2.0
LMS v1.1.0
</footer>
</div>
<div class="toast-host" id="toast-host" aria-live="polite">
<div th:if="${notice}" class="toast-item toast-success" data-toast>
<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 th:if="${error}" class="toast-item toast-error" data-toast>
<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 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>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
<span class="toast-text">
Новый пароль<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>
<script>
document.addEventListener('DOMContentLoaded', function () {
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();
});
}
});
});
</script>
<dialog id="modal-confirm" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="confirm-title">Подтверждение</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<p class="confirm-text" id="confirm-text">—</p>
<div class="flex justify-end gap-2 mt-4">
<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>
</body>
</html>
</html>

View File

@@ -46,34 +46,32 @@
</h1>
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть от студентов' : 'Опубликовать'">Скрыть</button>
<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>
<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>
</form>
<a class="btn btn-secondary btn-sm"
<a class="btn btn-secondary"
th:href="@{/learn/{c}(c=${article.chapterSlug})}">К главе</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</div>
<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:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<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:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
<span th:text="${nextArticle.title}">Следующая</span> →
@@ -90,8 +88,8 @@
<div class="md-editor-head">
<span class="md-editor-title">Редактирование статьи</span>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="cancelArticleEdit()">Отмена</button>
<button class="btn btn-primary btn-sm" type="submit">Сохранить</button>
<button class="btn btn-secondary" type="button" data-action="article-edit-cancel">Отмена</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</div>
<div class="card-body form-stack">
@@ -126,7 +124,7 @@
</div>
<script src="/js/mdeditor.js" defer></script>
<script th:src="@{/js/mdeditor.js(v='10')}" defer></script>
</div>
</body>
</html>

View File

@@ -15,27 +15,25 @@
</h1>
<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-secondary btn-sm" type="button" onclick="openModal('modal-rename-chapter')">Переименовать главу</button>
<button class="btn btn-primary" type="button" data-modal="modal-new-section">+ Раздел</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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть</button>
<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>
<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>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</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>
@@ -52,8 +50,8 @@
<div class="sub-head sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок разделов">⠿</span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)"
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"
title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span>
</button>
@@ -62,24 +60,20 @@
th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</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>
<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="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
<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>
<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>
</form>
<button class="btn btn-secondary btn-sm" type="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"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<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>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел вместе со статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</div>
</div>
@@ -89,22 +83,21 @@
<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"
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"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<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>
<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="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
<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>
<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>
</form>
</div>
</li>
@@ -116,8 +109,8 @@
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть"
th:attr="data-modal=|modal-rename-section-${section.id}|">✕</button>
</div>
<div class="modal-body">
<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">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть"
th:attr="data-modal=|modal-move-${section.id}|">✕</button>
</div>
<div class="modal-body">
<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="back" th:value="${back}">
<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>
<select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}"
@@ -157,7 +153,7 @@
<dialog id="modal-rename-chapter" class="modal">
<div class="modal-header">
<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 class="modal-body">
<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">
<div class="modal-header">
<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 class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">
@@ -187,7 +183,7 @@
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div>
</body>
</html>

View File

@@ -6,16 +6,11 @@
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1>
<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-secondary" type="button" onclick="openModal('modal-import')">Импорт zip</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
<button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</button>
<button class="btn btn-secondary" type="button" data-modal="modal-import">Импорт zip</button>
<button class="btn btn-secondary" type="button" data-modal="modal-new-chapter">+ Глава</button>
</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>
<span id="sort-status-text">Изменения не сохранены…</span>
</div>
@@ -29,7 +24,7 @@
<div class="sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
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>
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
<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="id" th:value="${chapter.id}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
<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>
<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>
</form>
<button class="btn btn-secondary btn-sm" type="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"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<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>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<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>
</div>
</div>
@@ -59,8 +51,8 @@
<div class="sub-head sort-row">
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок или перенести раздел">⠿</span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)"
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"
title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span>
</button>
@@ -73,18 +65,14 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
<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>
<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>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<button class="btn btn-secondary btn-sm" type="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"
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
<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>
<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>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел со всеми статьями?">
<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>
</div>
</div>
@@ -94,7 +82,7 @@
<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"
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"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
@@ -104,16 +92,14 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
<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>
<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>
</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>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<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>
</div>
</li>
@@ -125,8 +111,8 @@
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть"
th:attr="data-modal=|modal-rename-section-${section.id}|">✕</button>
</div>
<div class="modal-body">
<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">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть"
th:attr="data-modal=|modal-move-${section.id}|">✕</button>
</div>
<div class="modal-body">
<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>
<select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить раздел</button>
@@ -164,8 +151,8 @@
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть"
th:attr="data-modal=|modal-rename-chapter-${chapter.id}|">✕</button>
</div>
<div class="modal-body">
<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">
<div class="modal-header">
<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 class="modal-body">
<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">
<div class="modal-header">
<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 class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
@@ -223,7 +210,7 @@
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<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 class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -245,8 +232,8 @@
</div>
</dialog>
<script src="/js/mdeditor.js" defer></script>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/mdeditor.js(v='10')}" defer></script>
<script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div>
</body>
</html>

View File

@@ -17,28 +17,26 @@
</h1>
<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-secondary btn-sm" type="button" onclick="openModal('modal-rename-section')">Переименовать раздел</button>
<button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть</button>
<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>
<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>
</form>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-move-section')">Перенести статьи и удалить</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<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" data-confirm="Удалить раздел вместе со статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</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>
<span id="sort-status-text">Изменения не сохранены…</span>
@@ -52,28 +50,26 @@
<td>
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
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})}"
th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td>
<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>
<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="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
<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>
<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>
</form>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
</td>
</tr>
@@ -85,7 +81,7 @@
<dialog id="modal-rename-section" class="modal">
<div class="modal-header">
<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 class="modal-body">
<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">
<div class="modal-header">
<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 class="modal-body">
<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="back" th:value="${backParent}">
<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>
<select class="input" id="moveTo" name="moveTo">
<option th:each="pair : ${sectionChoices}"
@@ -122,7 +121,7 @@
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<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 class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -144,7 +143,7 @@
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/content-sort.js(v='10')}" defer></script>
</div>
</body>
</html>

View File

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

View File

@@ -7,9 +7,9 @@
<h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<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="password">Пароль</label><input class="input" id="password" name="password" type="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="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" 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" autocomplete="new-password" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>

View File

@@ -33,7 +33,7 @@
</h3>
<p class="text-sm" th:text="${c.title}">—</p>
<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:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</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">
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
<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>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
<h1 class="page-title mb-1" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
</div>
<p class="text-sm text-muted mb-6">
<span th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</span>
<p class="text-muted mb-6">
<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="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span>
<span th:if="${defenseMsk}"><b>Защита:</b> <span th:text="${deadlineMsk}"></span> МСК</span>
</p>
<h2 class="section-title">Условие лабораторной</h2>
@@ -28,7 +27,7 @@
<h2 class="section-title">Сдача работы</h2>
<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">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
@@ -41,13 +40,13 @@
<h3 class="group-title">История сдач</h3>
<div class="card card-flush mb-6">
<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>
<tr th:each="s : ${submissions}">
<td th:text="${s.seq}">1</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><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
<td class="font-mono text-sm" th:text="${#strings.listJoin(s.files, ', ')}">main.c</td>
<td><a class="btn btn-secondary" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
</tr>
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr>
</tbody>
@@ -57,7 +56,7 @@
<th:block th:if="${canSeeGrade}">
<h2 class="section-title">Оценка и комментарии</h2>
<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-col" th:if="${!#lists.isEmpty(commentsPositive)}">
<h3 class="group-title tone-positive-text">Позитивный</h3>
@@ -89,25 +88,26 @@
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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>
<button class="btn btn-primary" type="submit">Приступить к защите</button>
<input class="input w-260" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}"
inputmode="numeric" autocomplete="one-time-code" required>
<button class="btn btn-primary" type="submit">Приступить</button>
</form>
</th:block>
<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>
<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}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>
</div>
<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>
</div>
</div>
<script src="/js/defense-timer.js"></script>
<script th:src="@{/js/defense-timer.js(v='10')}" defer></script>
</div>
</body>
</html>

View File

@@ -11,7 +11,7 @@
<a th:href="@{/student/labs/{id}(id=${c.id})}" th:text="|Лабораторная работа ${c.number}. ${c.title}|">ЛР</a>
</h2>
<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:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>