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,354 @@
import { apiClient } from '../shared/api-client.js';
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
export const GROUP_TABLE_COLUMNS = 10;
export const GROUP_PAGE_SIZES = [25, 50, 100];
export const GROUP_SORT_OPTIONS = Object.freeze([
{ value: 'name,asc', label: 'По названию А–Я' },
{ value: 'name,desc', label: 'По названию Я–А' },
{ value: 'yearStartStudy,desc', label: 'Сначала новые наборы' },
{ value: 'groupSize,desc', label: 'Сначала крупные' },
{ value: 'status,asc', label: 'По статусу' }
]);
export const SUBGROUP_COUNT_OPTIONS = Object.freeze([
{ value: '0', label: 'Без разделения на подгруппы' },
{ value: '2', label: 'Две подгруппы' },
{ value: '3', label: 'Три подгруппы' }
]);
export const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export function normalizeGroupsState(restored = {}) {
const size = Number(restored.groupSize);
return {
query: restored.groupQuery || '',
page: Math.max(Number(restored.groupPage) || 0, 0),
size: GROUP_PAGE_SIZES.includes(size) ? size : 25,
sort: GROUP_SORT_OPTIONS.some(option => option.value === restored.groupSort)
? restored.groupSort
: 'name,asc',
educationFormIds: Array.isArray(restored.groupEducationForm)
? restored.groupEducationForm.map(String)
: []
};
}
export function initialGroupsState(restored = {}) {
return normalizeGroupsState(restored);
}
export function groupsQueryParams(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,
includeArchived: 'true'
});
(state.educationFormIds || []).forEach(id => params.append('educationFormId', id));
return `/api/groups/page?${params}`;
}
export function groupsUrlState(state) {
return {
groupQuery: state.query || '',
groupPage: state.page || '',
groupSize: state.size === 25 ? '' : state.size,
groupSort: state.sort === 'name,asc' ? '' : state.sort,
groupEducationForm: state.educationFormIds || []
};
}
// --- Справочники ---
export function fetchEducationForms(client = apiClient) {
// legacy молча подставляет пустой список при сбое загрузки форм обучения
return client.get('/api/education-forms').catch(() => []);
}
export function fetchDepartments(client = apiClient) {
return client.get('/api/departments', { errorMessage: 'Не удалось загрузить список кафедр' });
}
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Не удалось загрузить список специальностей' });
}
export function fetchAcademicYears(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Не удалось загрузить учебные годы' });
}
export async function fetchProfilesForSpecialties(specialties, client = apiClient) {
const pairs = await Promise.all((specialties || []).map(async (speciality) => {
const profiles = await client.get(
`/api/specialties/${speciality.id}/profiles`,
{ errorMessage: 'Не удалось загрузить профили обучения' }
);
return [String(speciality.id), profiles || []];
}));
return new Map(pairs);
}
// --- Реестр групп ---
export function fetchGroupsPage(state, client = apiClient) {
return client.get(groupsQueryParams(state), { errorMessage: 'Не удалось загрузить группы' });
}
export function groupPayloadFromFields(fields) {
const checks = [
['name', 'Введите название группы'],
['groupSize', 'Введите размер группы'],
['educationFormId', 'Выберите форму обучения'],
['departmentId', 'Выберите кафедру'],
['yearStartStudy', 'Введите год начала обучения'],
['specialtyId', 'Выберите специальность'],
['specialtyProfileId', 'Выберите профиль обучения']
];
for (const [field, message] of checks) {
if (!fields[field]) return { error: message };
}
return {
payload: {
name: fields.name,
groupSize: Number(fields.groupSize),
educationFormId: Number(fields.educationFormId),
departmentId: Number(fields.departmentId),
yearStartStudy: Number(fields.yearStartStudy),
specialtyId: Number(fields.specialtyId),
specialtyProfileId: Number(fields.specialtyProfileId)
}
};
}
export function createGroup(payload, client = apiClient) {
return client.post('/api/groups', payload, { errorMessage: 'Ошибка создания' });
}
export function updateGroup(id, payload, client = apiClient) {
return client.put(`/api/groups/${id}`, payload, { errorMessage: 'Ошибка обновления' });
}
export function archiveGroup(id, client = apiClient) {
return client.delete(`/api/groups/${id}`, { errorMessage: 'Ошибка архивирования' });
}
export function restoreGroup(id, client = apiClient) {
return client.post(`/api/groups/${id}/restore`, {}, { errorMessage: 'Ошибка восстановления' });
}
// --- Подгруппы ---
export function fetchSubgroups(groupId, client = apiClient) {
return client.get(`/api/groups/${groupId}/subgroups`, { errorMessage: 'Не удалось загрузить подгруппы' });
}
export function createSubgroup(groupId, payload, client = apiClient) {
return client.post(`/api/groups/${groupId}/subgroups`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
}
export function updateSubgroup(groupId, id, payload, client = apiClient) {
return client.put(`/api/groups/${groupId}/subgroups/${id}`, payload, { errorMessage: 'Ошибка сохранения подгруппы' });
}
export function deleteSubgroup(groupId, id, client = apiClient) {
return client.delete(`/api/groups/${groupId}/subgroups/${id}`, { errorMessage: 'Ошибка удаления подгруппы' });
}
export function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
export function sortSubgroups(subgroups) {
return [...(subgroups || [])].sort((left, right) =>
naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
export function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
export function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
// Синхронизация подгрупп воспроизводит legacy-порядок: сначала удаляем лишние,
// затем обновляем существующие (сначала уменьшающие вместимость), затем создаём новые.
export async function syncSubgroups(groupId, existingSubgroups, capacities, client = apiClient) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await deleteSubgroup(groupId, subgroup.id, client);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await updateSubgroup(groupId, update.existing.id, update.payload, client);
}
for (const payload of creates) {
await createSubgroup(groupId, payload, client);
}
}
// --- Календарные графики ---
export function calendarsOptionsEndpoint({ query, academicYearId, specialtyId, profileId, studyFormId, selectedId }) {
const params = new URLSearchParams({
query,
academicYearId,
specialtyId,
profileId,
studyFormId,
limit: '30'
});
if (selectedId) params.set('selectedId', selectedId);
return `/api/admin/academic-calendars/options?${params}`;
}
export function fetchCalendarsOptions(params, client = apiClient) {
return client.get(calendarsOptionsEndpoint(params), { errorMessage: 'Не удалось загрузить календарные графики' });
}
export function fetchCalendarAssignments(groupId, client = apiClient) {
return client.get(`/api/groups/${groupId}/calendar-assignments`, { errorMessage: 'Не удалось загрузить назначения графиков' });
}
export function saveCalendarAssignment(groupId, { academicYearId, calendarId }, client = apiClient) {
return client.put(
`/api/groups/${groupId}/calendar-assignments`,
{ academicYearId: Number(academicYearId), calendarId: Number(calendarId) },
{ errorMessage: 'Ошибка назначения графика' }
);
}
export function deleteCalendarAssignment(groupId, id, client = apiClient) {
return client.delete(`/api/groups/${groupId}/calendar-assignments/${id}`, { errorMessage: 'Ошибка удаления назначения' });
}
// --- Фильтр форм обучения ---
export function efFilterLabel(educationForms, selectedIds) {
const ids = selectedIds || [];
if (!ids.length) return 'Все';
if (ids.length === 1) {
const form = (educationForms || []).find(item => String(item.id) === ids[0]);
return form?.name || FILTER_EF_EMPTY_TEXT;
}
return `Выбрано: ${ids.length}`;
}
// --- Метки ---
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 specialityLabel(specialties, specialityId) {
const speciality = (specialties || []).find(item => String(item.id) === String(specialityId));
if (!speciality) return specialityId || '-';
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} — ${name}` : code;
}
export function specialityOptionLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code;
const name = speciality.specialityName || speciality.name;
return `${code} · ${name}`;
}
export function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
export function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
}
export function statusBadgeClass(group) {
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
return 'badge-unavailable';
}
if (group.studyState === 'NOT_STARTED') {
return 'badge-ef';
}
return 'badge-available';
}
export function calendarOptionLabel(calendar) {
return buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`;
}
export function calendarOptionDescription(calendar) {
return `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`;
}
export function assignmentCalendarTitle(assignment) {
return assignment.calendarTitle || '-';
}
export function assignmentSubjectsGroups(subjects) {
const grouped = new Map();
(subjects || []).forEach(subject => {
const key = String(subject.semesterNumber || '-');
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(subject);
});
return Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => ({ semesterNumber, items }));
}
export function groupsEmptyMessage(state) {
return state.query || (state.educationFormIds || []).length
? 'По заданным условиям группы не найдены'
: 'Групп пока нет';
}