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 c355190783
commit e4307f2248
4 changed files with 73 additions and 33 deletions

View File

@@ -74,24 +74,28 @@ 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-muted); text-decoration: none; }
.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: 1rem; font-weight: 700; color: var(--c-text); text-decoration: none; letter-spacing: -0.01em; }
.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-icon { width: 1.05rem; height: 1.05rem; display: block; }
.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)); }
/* 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; }
.toolbar .table-search { width: 260px; }
.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); }
@@ -483,11 +497,20 @@ dialog.modal.modal-lg .modal-body { max-height: 88vh; min-height: 62vh; }
.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; }
.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-title {
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>
<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="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})}"
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>
<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 class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Студенты и лабораторные</h2>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2" th:if="${!#lists.isEmpty(labs)}">
<div class="toolbar">
<input class="input table-search" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input" id="lab-focus" onchange="focusLab()">
@@ -78,7 +81,7 @@
<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"
@@ -133,14 +136,14 @@
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post"
onsubmit="return confirm('Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.')">
<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>
</form>
<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" type="submit">Отчислить</button>
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form>
</div>
</td>

View File

@@ -37,7 +37,15 @@
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(){
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);
@@ -99,18 +107,23 @@
<header class="page-header">
<div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS<b>.</b></a>
<nav class="nav">
<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:unless="${currentUser.admin}" th:href="@{/student}" 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">Войти</a>
<button type="button" onclick="toggleTheme()" class="btn btn-secondary 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>
<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="Сменить тему">
<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"/>
@@ -128,10 +141,10 @@
</svg>
</button>
<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}">
</form>
</nav>
</div>
</div>
</header>
<main class="page-main">