баг-фикс 30/34
This commit is contained in:
140
docs/FRONTEND.md
140
docs/FRONTEND.md
@@ -7,8 +7,8 @@
|
||||
| **Фреймворк** | Нет (Vanilla JavaScript) |
|
||||
| **Модульная система** | ES6 Modules (`import`/`export`) |
|
||||
| **Стили** | CSS (модульный подход) |
|
||||
| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания |
|
||||
| **Веб-сервер** | Apache httpd:alpine |
|
||||
| **Шрифт** | Системный стек без внешних font-CDN |
|
||||
| **Веб-сервер** | Apache httpd на Alpine со строгим CSP |
|
||||
|
||||
---
|
||||
|
||||
@@ -16,14 +16,24 @@
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── package.json # Dependency-free проверки frontend через node:test
|
||||
├── package.json # Exact build-зависимости и команды проверки
|
||||
├── package-lock.json # Зафиксированное npm-дерево
|
||||
├── auth-session.js # Access JWT и профиль сессии только в памяти вкладки
|
||||
├── telemetry.js # Same-origin загрузчик собранного OTel bundle
|
||||
├── security.conf # CSP и защитные HTTP-заголовки Apache
|
||||
├── telemetry/
|
||||
│ └── otel-entry.js # Исходная точка сборки OpenTelemetry
|
||||
├── scripts/
|
||||
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
|
||||
├── tests/
|
||||
│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
||||
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
||||
├── index.html # 🔐 Страница авторизации (общая)
|
||||
├── script.js # Логика авторизации
|
||||
├── style.css # Стили страницы авторизации
|
||||
├── theme-toggle.js # Переключение светлой/тёмной темы
|
||||
├── Dockerfile # httpd:alpine
|
||||
├── Dockerfile # Multi-stage: npm bundle → Apache httpd
|
||||
│
|
||||
├── admin/ # 👨💼 Интерфейс администратора
|
||||
│ ├── index.html # SPA-оболочка с sidebar
|
||||
@@ -36,10 +46,10 @@ frontend/
|
||||
│ │ └── departments-data.css # Стили создания кафедры/специальности
|
||||
│ ├── js/
|
||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
||||
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
||||
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
|
||||
│ │ ├── utils.js # Утилиты
|
||||
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
|
||||
│ │ └── views/ # Модули представлений
|
||||
│ │ ├── dashboard.js # Дашборд
|
||||
│ │ ├── users.js # Управление пользователями
|
||||
@@ -81,14 +91,18 @@ frontend/
|
||||
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
|
||||
│
|
||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
||||
│ ├── index.html # CSP-совместимая HTML-оболочка
|
||||
│ ├── app.js # Недельный просмотр и общий auth-session
|
||||
│ └── style.css # Стили кабинета без inline-блока
|
||||
├── department/ # 🏛 Кабинет кафедры
|
||||
│ └── index.html # Redirect в `/admin/#department-workspace`
|
||||
├── edu-office/ # 🗓 Кабинет учебного отдела
|
||||
│ └── index.html # Redirect в `/admin/#schedule-view`
|
||||
│
|
||||
└── student/ # 🎓 Интерфейс студента
|
||||
└── index.html # Недельный просмотр динамического расписания группы
|
||||
├── index.html # CSP-совместимая HTML-оболочка
|
||||
├── app.js # Недельный просмотр и общий auth-session
|
||||
└── style.css # Стили кабинета без inline-блока
|
||||
```
|
||||
|
||||
---
|
||||
@@ -112,12 +126,13 @@ frontend/
|
||||
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
|
||||
4. Обновляет заголовок страницы (`#page-title`)
|
||||
|
||||
`main.js` также фильтрует вкладки по роли:
|
||||
`main.js` и отдельный settings SPA получают разрешённые вкладки из единого
|
||||
`admin/js/role-capabilities.js`; локальные дубли `ROLE_NAVIGATION`/`ROLE_TABS` удалены.
|
||||
|
||||
| Роль | Доступные вкладки |
|
||||
|------|-------------------|
|
||||
| `ADMIN` | Все вкладки |
|
||||
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории, оборудование |
|
||||
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
|
||||
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний |
|
||||
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
|
||||
|
||||
@@ -139,14 +154,14 @@ frontend/
|
||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
||||
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
|
||||
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
||||
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
|
||||
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
|
||||
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
||||
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
||||
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
||||
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
||||
@@ -168,47 +183,32 @@ frontend/
|
||||
- Кнопка «Назад в панель» для возврата в `/admin/`
|
||||
- Текущие вкладки:
|
||||
- **Общие настройки** — заглушка (только `ADMIN`)
|
||||
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
|
||||
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Поле длительности доступно только для чтения и пересчитывается при изменении времени начала или окончания; в API отправляются только границы, а окончательное значение вычисляет backend. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
|
||||
|
||||
---
|
||||
|
||||
## API-клиент (`api.js`)
|
||||
|
||||
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
|
||||
Все защищённые HTTP-запросы проходят через `fetchWithAuth()` из `auth-session.js`.
|
||||
Access JWT и `role`/`departmentId`/`userId` существуют только в памяти JavaScript-модуля:
|
||||
|
||||
```javascript
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: getHeaders(body ? 'application/json' : null),
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) return apiFetch(endpoint, method, body, false);
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
export async function fetchWithAuth(endpoint, options = {}, retry = true) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
const response = await fetch(endpoint, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && retry && await refreshAccessToken()) {
|
||||
return fetchWithAuth(endpoint, options, false);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return response;
|
||||
}
|
||||
|
||||
// Shortcut-методы
|
||||
export const api = {
|
||||
get: (url) => apiFetch(url, 'GET'),
|
||||
post: (url, body) => apiFetch(url, 'POST', body),
|
||||
put: (url, body) => apiFetch(url, 'PUT', body),
|
||||
delete: (url, body) => apiFetch(url, 'DELETE', body)
|
||||
};
|
||||
```
|
||||
|
||||
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
|
||||
При загрузке защищённой страницы память восстанавливается через `POST /api/auth/refresh` и
|
||||
`HttpOnly` cookie. При `401` клиент выполняет не более одной общей refresh-ротации для всех
|
||||
параллельных запросов и один раз повторяет исходный запрос. Неуспешный refresh очищает память
|
||||
и возвращает пользователя на страницу входа. Legacy auth-ключи только удаляются из
|
||||
`localStorage`/`sessionStorage`; новые данные авторизации туда не записываются.
|
||||
|
||||
После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
|
||||
|
||||
@@ -216,13 +216,21 @@ export const api = {
|
||||
|
||||
## Frontend-тесты
|
||||
|
||||
Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:00–03:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
|
||||
Регрессионные проверки используют встроенный `node:test` (Node.js 18+). Помимо тестов
|
||||
дашборда, `auth-session.test.mjs` покрывает login-state, reload через refresh-cookie,
|
||||
single-flight ротацию, повтор после `401` и logout. `security-policy.test.mjs` запрещает
|
||||
auth-данные в Web Storage, удалённые/inline-скрипты, динамические inline-стили и рассинхрон
|
||||
SHA-256 style-хэшей с CSP. Там же закреплены безопасный DOM-рендеринг ошибок без
|
||||
интерполяции `exception.message` в `innerHTML`, скрытые парольные поля с корректным
|
||||
`autocomplete`, русские метки tenant-контура и отсутствие англоязычных/raw exception
|
||||
сообщений в собственных frontend-логах.
|
||||
|
||||
Команды выполняются из каталога `frontend/`:
|
||||
|
||||
```bash
|
||||
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
|
||||
npm run check # синтаксис модулей дашборда + unit-тесты
|
||||
npm run build # собрать локальный dist/vendor/otel.js
|
||||
npm test # frontend unit/static tests
|
||||
npm run check # синтаксис auth/UI-модулей + все frontend-тесты
|
||||
```
|
||||
|
||||
---
|
||||
@@ -233,13 +241,10 @@ npm run check # синтаксис модулей дашборда + unit-те
|
||||
|
||||
1. Пользователь вводит логин/пароль
|
||||
2. `script.js` отправляет `POST /api/auth/login`
|
||||
3. При успехе сохраняет в `localStorage`:
|
||||
- `token` — access JWT
|
||||
- `role` — роль пользователя
|
||||
- `departmentId` — кафедра пользователя
|
||||
- `userId` — ID пользователя для личного расписания преподавателя
|
||||
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
|
||||
5. Перенаправляет на соответствующий интерфейс:
|
||||
3. При успехе кладёт access JWT и профиль пользователя только в память текущей страницы.
|
||||
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript.
|
||||
5. После перехода новый документ восстанавливает память через `POST /api/auth/refresh`.
|
||||
6. Пользователь перенаправляется на соответствующий интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||
@@ -249,18 +254,19 @@ npm run check # синтаксис модулей дашборда + unit-те
|
||||
|
||||
### Проверка авторизации
|
||||
|
||||
На каждой странице проверяется наличие токена и роли:
|
||||
Каждая защищённая страница сначала восстанавливает сессию и проверяет роль из памяти:
|
||||
|
||||
```javascript
|
||||
export function isAuthenticatedAsAdmin() {
|
||||
const role = localStorage.getItem('role');
|
||||
return getToken() && role === 'ADMIN';
|
||||
const session = await restoreSession();
|
||||
if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
|
||||
window.location.replace('/');
|
||||
}
|
||||
```
|
||||
|
||||
### Выход
|
||||
|
||||
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
|
||||
Кнопка «Выйти» вызывает `POST /api/auth/logout`, очищает access JWT и профиль из памяти и
|
||||
перенаправляет на `/`. Refresh-cookie очищает backend.
|
||||
|
||||
---
|
||||
|
||||
@@ -292,7 +298,8 @@ export function isAuthenticatedAsAdmin() {
|
||||
|
||||
### Преподаватель (`/teacher/`)
|
||||
|
||||
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`.
|
||||
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из
|
||||
восстановленного в памяти профиля сессии.
|
||||
|
||||
Основные элементы:
|
||||
- навигация по неделям: предыдущая, текущая, следующая;
|
||||
@@ -300,7 +307,7 @@ export function isAuthenticatedAsAdmin() {
|
||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
||||
|
||||
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
|
||||
Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход.
|
||||
|
||||
### Студент (`/student/`)
|
||||
|
||||
@@ -368,17 +375,18 @@ CSS-переменные позволяют поддерживать светл
|
||||
|
||||
---
|
||||
|
||||
## OpenTelemetry (`otel.js`)
|
||||
## OpenTelemetry (локальный bundle)
|
||||
|
||||
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`.
|
||||
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на
|
||||
same-origin путь `/otel/v1/traces`.
|
||||
|
||||
- **На production** — загружается автоматически через динамический `import()`
|
||||
- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh`
|
||||
- npm-зависимости зафиксированы exact-версиями в `package-lock.json`;
|
||||
- `scripts/build-vendor.mjs` собирает их через esbuild в `/vendor/otel.js`;
|
||||
- браузер импортирует только same-origin bundle, CDN-код в runtime отсутствует;
|
||||
- на `localhost` телеметрия не включается.
|
||||
|
||||
```javascript
|
||||
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
||||
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
||||
}
|
||||
telemetryPromise = import('/vendor/otel.js');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user