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,153 @@
import { apiClient } from '../shared/api-client.js';
export const SUBJECT_TABLE_COLUMNS = 6;
export const SUBJECT_PAGE_SIZES = [25, 50, 100];
export const SUBJECT_SORT_OPTIONS = Object.freeze([
{ value: 'name,asc', label: 'По названию А–Я' },
{ value: 'name,desc', label: 'По названию Я–А' },
{ value: 'code,asc', label: 'По коду' },
{ value: 'departmentId,asc', label: 'По кафедре' }
]);
const DEFAULT_STATE = Object.freeze({
query: '',
departmentId: '',
page: 0,
size: 25,
sort: 'name,asc'
});
export function normalizeSubjectsState(restored = {}) {
const size = Number(restored.subjectSize);
return {
query: restored.subjectQuery || '',
departmentId: restored.subjectDepartment || '',
page: Math.max(Number(restored.subjectPage) || 0, 0),
size: SUBJECT_PAGE_SIZES.includes(size) ? size : 25,
sort: SUBJECT_SORT_OPTIONS.some(option => option.value === restored.subjectSort)
? restored.subjectSort
: 'name,asc'
};
}
export function initialSubjectsState(restored = {}) {
return normalizeSubjectsState(restored);
}
export function defaultSubjectsState() {
return { ...DEFAULT_STATE };
}
export function subjectsQueryParams(state) {
const [sort, direction] = String(state.sort || 'name,asc').split(',');
const params = new URLSearchParams({
query: state.query || '',
page: String(state.page || 0),
size: String(state.size || 25),
sort,
direction
});
if (state.departmentId) params.set('departmentId', state.departmentId);
return `/api/subjects/page?${params}`;
}
export function subjectsUrlState(state) {
return {
subjectQuery: state.query || '',
subjectDepartment: state.departmentId || '',
subjectPage: state.page || '',
subjectSize: state.size === 25 ? '' : state.size,
subjectSort: state.sort === 'name,asc' ? '' : state.sort
};
}
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
export function fetchSubjectsPage(state, client = apiClient) {
return client.get(subjectsQueryParams(state), { errorMessage: 'Не удалось загрузить дисциплины' });
}
export function teacherAssignmentsEndpoint(subjectIds) {
const params = new URLSearchParams();
(subjectIds || []).forEach(subjectId => params.append('subjectId', subjectId));
return `/api/teacher-subjects?${params}`;
}
export function fetchTeacherAssignments(subjectIds, client = apiClient) {
if (!(subjectIds || []).length) return Promise.resolve([]);
return client.get(
teacherAssignmentsEndpoint(subjectIds),
{ errorMessage: 'Не удалось загрузить привязки преподавателей' }
);
}
export function createSubject({ name, code, departmentId }, client = apiClient) {
return client.post(
'/api/subjects',
{ name, code, departmentId: Number(departmentId) },
{ errorMessage: 'Ошибка создания' }
);
}
export function archiveSubject(id, client = apiClient) {
return client.delete(`/api/subjects/${id}`, { errorMessage: 'Ошибка архивирования' });
}
export function assignTeacher({ userId, subjectId }, client = apiClient) {
return client.post(
'/api/teacher-subjects',
{ userId: Number(userId), subjectId: Number(subjectId) },
{ errorMessage: 'Ошибка привязки' }
);
}
export function removeTeacherAssignment({ userId, subjectId }, client = apiClient) {
return client.delete('/api/teacher-subjects', {
json: { userId: Number(userId), subjectId: Number(subjectId) },
errorMessage: 'Ошибка удаления привязки'
});
}
export function teachersSearchEndpoint(query, selectedId) {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export function departmentLabel(departments, departmentId) {
const department = (departments || []).find(item => String(item.id) === String(departmentId));
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
export function departmentOptionLabel(department) {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `${name} (${code})`;
}
export function groupTeachersBySubject(assignments) {
const grouped = new Map();
(assignments || []).forEach(assignment => {
const key = String(assignment.subjectId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(assignment);
});
return grouped;
}
export function subjectContextLabel(subject) {
return `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
}
export function subjectsEmptyMessage(state) {
return state.query || state.departmentId ? 'Дисциплины не найдены' : 'Дисциплин пока нет';
}
export function normalizePageState(state, page) {
const nextPage = Number(page);
return { ...state, page: Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0 };
}