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,150 @@
import { apiClient } from '../shared/api-client.js';
export const DEPARTMENT_TABLE_COLUMNS = 4;
export const SPECIALTY_TABLE_COLUMNS = 4;
export const PROFILE_TABLE_COLUMNS = 5;
export const LIST_PAGE_SIZES = [25, 50, 100];
// --- Загрузка ---
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить кафедры' });
}
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить специальности' });
}
export function fetchAllProfiles(client = apiClient) {
return client.get('/api/specialties/profiles', { errorMessage: 'Не удалось загрузить профили обучения' });
}
export function fetchProfiles(specId, client = apiClient) {
return client.get(`/api/specialties/${specId}/profiles`, { errorMessage: 'Не удалось загрузить профили обучения' });
}
// --- Кафедры ---
export function createDepartment({ name, code }, client = apiClient) {
return client.post(
'/api/departments',
{ departmentName: name, departmentCode: Number(code) },
{ errorMessage: 'Ошибка создания кафедры' }
);
}
export function updateDepartment(id, { name, code }, client = apiClient) {
return client.put(
`/api/departments/${id}`,
{ departmentName: name, departmentCode: Number(code) },
{ errorMessage: 'Ошибка обновления кафедры' }
);
}
export function deleteDepartment(id, client = apiClient) {
return client.delete(`/api/departments/${id}`, { errorMessage: 'Ошибка удаления кафедры' });
}
// --- Специальности ---
export function createSpecialty({ name, code }, client = apiClient) {
return client.post(
'/api/specialties',
{ specialityName: name, specialityCode: code },
{ errorMessage: 'Ошибка создания специальности' }
);
}
export function updateSpecialty(id, { name, code }, client = apiClient) {
return client.put(
`/api/specialties/${id}`,
{ specialityName: name, specialityCode: code },
{ errorMessage: 'Ошибка обновления специальности' }
);
}
export function deleteSpecialty(id, client = apiClient) {
return client.delete(`/api/specialties/${id}`, { errorMessage: 'Ошибка удаления специальности' });
}
// --- Профили ---
export function createProfile(specId, { name, description }, client = apiClient) {
return client.post(
`/api/specialties/${specId}/profiles`,
{ name, description },
{ errorMessage: 'Ошибка сохранения профиля' }
);
}
export function updateProfile(specId, profileId, { name, description }, client = apiClient) {
return client.put(
`/api/specialties/${specId}/profiles/${profileId}`,
{ id: Number(profileId), name, description },
{ errorMessage: 'Ошибка сохранения профиля' }
);
}
export function deleteProfile(specId, profileId, client = apiClient) {
return client.delete(`/api/specialties/${specId}/profiles/${profileId}`, { errorMessage: 'Ошибка удаления профиля' });
}
// --- Клиентская фильтрация и пагинация списков (как в legacy listPage) ---
export function filterListItems(items, query, fields) {
const normalized = String(query || '').trim().toLocaleLowerCase('ru');
if (!normalized) return [...(items || [])];
return (items || []).filter(item =>
fields(item).join(' ').toLocaleLowerCase('ru').includes(normalized));
}
export function clampPage(page, totalPages) {
return Math.min(Math.max(Number(page) || 0, 0), Math.max(0, totalPages - 1));
}
export function pageSlice(items, page, size) {
const pageSize = Number(size) || 25;
return (items || []).slice(page * pageSize, (page + 1) * pageSize);
}
export function departmentFields(department) {
return [department.id, department.departmentName || department.name, department.departmentCode ?? department.code];
}
export function specialtyFields(speciality) {
return [
speciality.id,
speciality.specialityName || speciality.name,
speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code
];
}
export function profileFields(profile) {
return [profile.id, profile.name, profile.description, profile.specialityCode, profile.specialityName];
}
// --- Метки ---
export function departmentName(department) {
return department.departmentName || department.name;
}
export function departmentCode(department) {
return department.departmentCode ?? department.code;
}
export function specialtyName(speciality) {
return speciality.specialityName || speciality.name;
}
export function specialtyCode(speciality) {
return speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
}
export function specialtyOptionLabel(speciality) {
return `${specialtyCode(speciality)} · ${specialtyName(speciality)}`;
}
export function profileSpecialtyLabel(profile) {
return `${profile.specialityCode} · ${profile.specialityName}`;
}