Group page: B3 layout (page-head, toolbar, matrix), neutral header with accent logo dot

This commit is contained in:
2026-10-02 16:37:00 +03:00
parent 3c54830f48
commit ece700d91b
4 changed files with 73 additions and 33 deletions

View File

@@ -281,6 +281,7 @@ class AdminGroupController(
model.addAttribute("studentGrades", view.rows) model.addAttribute("studentGrades", view.rows)
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }
/** Фрагмент большой модалки «Выдача вариантов»: вся группа одной таблицей по выбранной лабе. */ /** Фрагмент большой модалки «Выдача вариантов»: вся группа одной таблицей по выбранной лабе. */

View File

@@ -74,24 +74,28 @@ a:not(.btn):hover { color: var(--c-text-link-hover); }
/* Layout */ /* Layout */
.page { display: flex; flex-direction: column; min-height: 100vh; } .page { display: flex; flex-direction: column; min-height: 100vh; }
.page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); } .page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); }
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; } .page-header-inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 0.5rem 1.75rem; flex-wrap: wrap; padding: 0.875rem 1.75rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
.page-header-inner { padding: 0.5rem 0.75rem; } .page-header-inner { padding: 0.5rem 0.875rem; gap: 0.5rem 1rem; }
.nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; } .nav { gap: 0.5rem 0.75rem; flex-wrap: wrap; }
.nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; } .user { gap: 0.5rem; }
.nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
} }
.page-main { flex: 1; width: 100%; max-width: 1152px; margin: 0 auto; padding: 2rem 1.5rem; } .page-main { flex: 1; width: 100%; max-width: 1200px; margin: 0 auto; padding: 2.5rem 1.75rem 3.5rem; }
.page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); } @media (max-width: 720px) { .page-main { padding: 1.5rem 0.875rem 2.5rem; } }
.page-footer { text-align: center; color: var(--c-text-muted); font-size: 0.75rem; padding: 1.5rem; }
/* Nav */ /* Nav */
.nav { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; } .nav { display: flex; align-items: center; gap: 1.125rem; flex: 1 1 auto; flex-wrap: wrap; font-size: 0.84375rem; }
.nav-link { color: var(--c-text-muted); text-decoration: none; } .nav-link { color: var(--c-text-muted); text-decoration: none; font-weight: 500; }
.nav-link:hover { color: var(--c-text); } .nav-link:hover { color: var(--c-text); }
.nav-link.active { color: var(--c-accent); }
.nav-sep { color: var(--c-text-muted); } .nav-sep { color: var(--c-text-muted); }
.logo { font-size: 1rem; font-weight: 700; color: var(--c-text); text-decoration: none; letter-spacing: -0.01em; } .logo { font-size: 1rem; font-weight: 700; color: var(--c-text); text-decoration: none; letter-spacing: -0.01em; }
.logo b { color: var(--c-accent); } .logo b { color: var(--c-accent); }
.nav-user { font-weight: 500; } .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); }
.theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; } .theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; }
.theme-icon { width: 1.05rem; height: 1.05rem; display: block; } .theme-icon { width: 1.05rem; height: 1.05rem; display: block; }
.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } .breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
@@ -138,8 +142,18 @@ a:not(.btn):hover { color: var(--c-text-link-hover); }
.grid-stats { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } .grid-stats { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
/* Typography */ /* Typography */
.page-title { margin: 0 0 1.5rem; font-size: 1.5rem; font-weight: 600; } .page-title { margin: 0 0 1.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
.section-title { margin: 0 0 0.75rem; font-size: 1.125rem; font-weight: 500; } .section-title { margin: 0 0 0.75rem; font-size: 1.125rem; font-weight: 500; }
/* Шапка страницы в стиле Б3: kicker + заголовок + мета, действия справа */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.75rem; flex-wrap: wrap; }
.page-head .page-title { margin: 0 0 0.5rem; }
.kicker { margin: 0 0 0.375rem; font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted); }
.meta { margin: 0; color: var(--c-text-muted); font-size: 0.8125rem; }
.meta b { color: var(--c-text); font-weight: 600; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.toolbar .table-search { width: 260px; }
.stat-value { margin: 0.5rem 0 0; font-size: 1.875rem; font-weight: 600; } .stat-value { margin: 0.5rem 0 0; font-size: 1.875rem; font-weight: 600; }
.stat-title { font-size: 0.875rem; color: var(--c-text-muted); margin: 0; } .stat-title { font-size: 0.875rem; color: var(--c-text-muted); margin: 0; }
.text-muted { color: var(--c-text-muted); } .text-muted { color: var(--c-text-muted); }
@@ -483,11 +497,20 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.font-medium { font-weight: 500; } .font-medium { font-weight: 500; }
.scroll-x { overflow-x: auto; } .scroll-x { overflow-x: auto; }
.matrix { border-collapse: separate; border-spacing: 0; } .matrix { border-collapse: separate; border-spacing: 0; }
.matrix th { vertical-align: top; } .matrix th {
.matrix td { vertical-align: middle; } vertical-align: top; padding: 0.75rem 1rem; text-align: left;
font-size: 0.75rem; font-weight: 500; color: var(--c-text-muted);
background: var(--c-surface); white-space: nowrap;
}
.matrix td { vertical-align: top; padding: 1rem; }
.matrix tbody tr:hover td { background: rgba(19, 19, 22, 0.03); }
[data-theme="dark"] .matrix tbody tr:hover td { background: rgba(232, 233, 236, 0.045); }
.matrix tbody tr:last-child td { border-bottom: none; }
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
.matrix td.student-cell { min-width: 12rem; } .matrix td.student-cell { min-width: 12rem; }
.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: center; gap: 0.375rem; } .lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
.lab-col-title { .lab-col-title {
max-width: 9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;

View File

@@ -3,25 +3,28 @@
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head> <head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body> <body>
<div id="content"> <div id="content">
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a> <div class="page-head">
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1> <p class="kicker">Ведомость группы</p>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p> <h1 class="page-title" th:text="${group.name}">Группа</h1>
<p class="meta">
<span th:text="${#lists.size(group.students)}">0</span> студентов ·
<b th:text="${publishedLabsCount}">0</b>
<span th:with="p=${publishedLabsCount}"
th:text="${(p % 10 == 1 and p % 100 != 11) ? 'лабораторная' : ((p % 10 >= 2 and p % 10 <= 4 and (p % 100 < 12 or p % 100 > 14)) ? 'лабораторные' : 'лабораторных')}">лабораторных</span>
с открытыми оценками
</p>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт заданий</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/submissions.zip(id=${group.id})}" <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/submissions.zip(id=${group.id})}"
title="ZIP со всеми последними решениями группы по всем лабораторным">Скачать работы</a> title="ZIP со всеми последними решениями группы по всем лабораторным">Скачать работы</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-defense-import')">Импорт заданий</button> <button class="btn" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2"> <div class="toolbar">
<h2 class="section-title m-0">Студенты и лабораторные</h2>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2" th:if="${!#lists.isEmpty(labs)}">
<input class="input table-search" data-target="group-table" <input class="input table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту"> placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input" id="lab-focus" onchange="focusLab()"> <select class="input" id="lab-focus" onchange="focusLab()">
@@ -78,7 +81,7 @@
<tbody> <tbody>
<tr th:each="row : ${studentGrades}"> <tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky"> <td class="student-cell matrix-sticky">
<div class="font-medium" th:text="${row.fullName}">—</div> <div class="student-name" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div> <div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</td> </td>
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab" <td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
@@ -133,14 +136,14 @@
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" <form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post"
onsubmit="return confirm('Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.')"> onsubmit="return confirm('Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit" <button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button> title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form> </form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}" <form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')"> onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger" type="submit">Отчислить</button> <button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form> </form>
</div> </div>
</td> </td>

View File

@@ -37,7 +37,15 @@
document.querySelectorAll('pre code').forEach(function(el){ window.hljs.highlightElement(el); }); 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(); }); document.addEventListener('DOMContentLoaded', function(){ window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); syncModalScrollLock(); initNavActive(); initSections(); });
/* Активный пункт меню — как в макете: бордовый цвет у текущего раздела. */
function initNavActive(){
var path = window.location.pathname;
document.querySelectorAll('#top-nav .nav-link').forEach(function (a) {
var href = a.getAttribute('href') || '';
if (path === href || (href !== '/' && path.indexOf(href + '/') === 0)) a.classList.add('active');
});
}
function toggleTheme(){ function toggleTheme(){
var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; 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); e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); window.__hljsSet(t);
@@ -99,18 +107,23 @@
<header class="page-header"> <header class="page-header">
<div class="page-header-inner"> <div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS<b>.</b></a> <a th:href="@{/}" class="logo">LMS<b>.</b></a>
<nav class="nav"> <nav class="nav" id="top-nav">
<th:block th:if="${currentUser != null}"> <th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a> <a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/users}" class="nav-link">Пользователи</a> <a th:if="${currentUser.admin}" th:href="@{/admin/users}" class="nav-link">Пользователи</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a> <a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a> <a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
<a th:unless="${currentUser.admin}" th:href="@{/student}" class="nav-link">Личный кабинет</a>
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a> <a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:unless="${currentUser.admin}" th:href="@{/student}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
</th:block> </th:block>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary">Войти</a> </nav>
<button type="button" onclick="toggleTheme()" class="btn btn-secondary theme-btn" <div class="user">
<th:block th:if="${currentUser != null}">
<a th:href="@{/admin}" th:if="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:unless="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
</th:block>
<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"
aria-label="Сменить тему" title="Сменить тему"> aria-label="Сменить тему" title="Сменить тему">
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/> <circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/>
@@ -128,10 +141,10 @@
</svg> </svg>
</button> </button>
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post"> <form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
<button class="btn btn-secondary" type="submit">Выйти</button> <button class="btn btn-secondary btn-sm" type="submit">Выйти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>
</nav> </div>
</div> </div>
</header> </header>
<main class="page-main"> <main class="page-main">