feat: завершить миграцию фронтенда на React (этапы 1-5)

Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,131 @@
import { apiClient } from '../shared/api-client.js';
import { capabilitiesForRole } from '../../admin/js/role-capabilities.js';
// Вкладки, перенесённые на React (см. react-tabs.js), хранят только заголовок:
// их содержимое рендерят React-компоненты, HTML-фрагменты не загружаются.
export const ADMIN_ROUTES = Object.freeze({
dashboard: { title: 'Дашборд' },
users: { title: 'Управление пользователями' },
'teacher-requests': { title: 'Заявки' },
groups: { title: 'Управление группами' },
classrooms: { title: 'Аудиторный фонд' },
subjects: { title: 'Дисциплины' },
'university-structure': { title: 'Структура вуза' },
'department-workspace': { title: 'Кабинет кафедры' },
'schedule-view': { title: 'Просмотр расписаний' },
schedule: { title: 'Конструктор правил' },
'schedule-versions': { title: 'Версии расписания' },
'schedule-quality': { title: 'Анализ качества расписания' },
'academic-calendar': { title: 'Календарный учебный график' },
'auditorium-workload': { title: 'Загруженность' },
'teacher-absences': { title: 'Запросы преподавателей' }
});
const NAV_ITEMS = Object.freeze({
dashboard: { navLabel: 'Дашборд' },
'teacher-requests': { navLabel: 'Заявки' },
'department-workspace': { navLabel: 'Кабинет кафедры' },
schedule: { navLabel: 'Конструктор правил' },
'schedule-versions': { navLabel: 'Версии расписания' },
'schedule-quality': { navLabel: 'Качество расписания' },
'academic-calendar': { navLabel: 'Календарный график' },
'auditorium-workload': { navLabel: 'Загруженность' },
'schedule-view': { navLabel: 'Просмотр расписаний' },
'teacher-absences': { navLabel: 'Запросы преподавателей' },
classrooms: { navLabel: 'Аудиторный фонд' },
groups: { navLabel: 'Группы обучения' },
'university-structure': { navLabel: 'Структура вуза' },
subjects: { navLabel: 'Дисциплины' },
users: { navLabel: 'Пользователи' }
});
const NAV_SECTIONS = Object.freeze([
{ title: '', items: Object.freeze(['dashboard', 'teacher-requests', 'department-workspace']) },
{
title: 'Расписание',
items: Object.freeze([
'schedule',
'schedule-versions',
'schedule-quality',
'academic-calendar',
'auditorium-workload',
'schedule-view',
'teacher-absences'
])
},
{
title: 'Ресурсы',
items: Object.freeze(['classrooms', 'groups', 'university-structure', 'subjects'])
},
{ title: 'Система', items: Object.freeze(['users']) }
]);
const LOGO_TITLES = Object.freeze({
ADMIN: 'Magistr',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Расписания'
});
export function allowedAdminTabs(role) {
const capabilities = capabilitiesForRole(role);
return new Set(capabilities ? capabilities.adminTabs : []);
}
export function navSectionsForRole(role) {
const allowed = allowedAdminTabs(role);
return NAV_SECTIONS
.map(section => ({
title: section.title,
items: section.items
.filter(tab => allowed.has(tab))
.map(tab => ({ id: tab, label: NAV_ITEMS[tab].navLabel }))
}))
.filter(section => section.items.length > 0);
}
export function logoTitleForRole(role) {
return LOGO_TITLES[role] || 'Magistr';
}
export function defaultAdminTabForRole(role) {
const capabilities = capabilitiesForRole(role);
return capabilities ? capabilities.defaultAdminTab : null;
}
export function tabFromHash(hash, role) {
const allowed = allowedAdminTabs(role);
const candidate = String(hash || '').replace('#', '');
if (candidate && allowed.has(candidate)) return candidate;
return defaultAdminTabForRole(role);
}
export function requestCountLabel(count) {
const value = Number(count) || 0;
const mod10 = value % 10;
const mod100 = value % 100;
if (mod10 === 1 && mod100 !== 11) return `${value} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${value} заявки`;
return `${value} заявок`;
}
export function navBadgeLabel(count) {
const value = Number(count) || 0;
return value > 99 ? '99+' : String(value);
}
export function badgeVisibility(count) {
const value = Number(count) || 0;
return {
hidden: value <= 0,
ariaLabel: value > 0 ? `Заявок в очереди: ${value}` : 'Заявок в очереди нет'
};
}
export async function fetchPendingRequestsCount(client = apiClient) {
const page = await client.get(
'/api/teacher-requests/page?status=PENDING&page=0&size=1',
{ errorMessage: 'Не удалось загрузить счётчик заявок' }
);
return Number(page?.totalItems || 0);
}