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,641 @@
import { apiClient } from '../shared/api-client.js';
import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js';
import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js';
export const DAY_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }),
Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }),
Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }),
Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }),
Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }),
Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }),
Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' })
]);
export const SEMESTER_LABELS = Object.freeze({
autumn: 'осенний',
spring: 'весенний'
});
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
// --- Позиция подсказки ячейки ---
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
// --- Учебный год ---
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
// Ввод года начала: нормализует значение и пересчитывает год окончания
// вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты
// семестров к рассчитанным значениям (semesterDates === null — не трогать).
export function startYearInputResult(rawValue) {
const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4);
if (startYear.length !== 4) {
return { startYear, endYear: '', semesterDates: null };
}
try {
const defaults = academicYearDefaults(startYear);
return {
startYear,
endYear: String(defaults.endYear),
semesterDates: {
autumn: {
start: isoDateToInputValue(defaults.semesters[0].startDate),
end: isoDateToInputValue(defaults.semesters[0].endDate)
},
spring: {
start: isoDateToInputValue(defaults.semesters[1].startDate),
end: isoDateToInputValue(defaults.semesters[1].endDate)
}
}
};
} catch {
return { startYear, endYear: '', semesterDates: null };
}
}
// Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке
// (реплика legacy readDateInputIso).
export function readDisplayDateIso(displayValue, label = 'Дата') {
try {
return dateInputValueToIso(displayValue);
} catch (error) {
throw new Error(`${label}: ${lowercaseFirst(error.message)}`);
}
}
// --- Типы активности ---
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export function activityById(id, activityTypes) {
return (activityTypes || []).find(type => String(type.id) === String(id));
}
export function activityIdByCode(code, activityTypes) {
return (activityTypes || []).find(type => type.code === code)?.id || null;
}
export function theoryActivityId(activityTypes) {
return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || '';
}
// Эффективный код активности ячейки: сохранённое значение, затем код по
// справочнику, затем теория (реплика legacy renderCalendarDay).
export function effectiveActivityTypeId(row, activityTypes) {
return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes);
}
// --- Контракты API ---
export function fetchSpecialties(client = apiClient) {
return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' });
}
export function specialtyProfilesEndpoint(specialtyId) {
return `/api/specialties/${specialtyId}/profiles`;
}
export function fetchSpecialtyProfiles(specialtyId, client = apiClient) {
return client.get(specialtyProfilesEndpoint(specialtyId), {
errorMessage: 'Ошибка загрузки профилей специальности'
});
}
export function fetchAcademicYearsList(client = apiClient) {
return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' });
}
export function fetchSubjectsList(client = apiClient) {
return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' });
}
export function fetchEducationForms(client = apiClient) {
return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' });
}
export function fetchActivityTypes(client = apiClient) {
return client.get('/api/admin/calendar/activity-types', {
errorMessage: 'Ошибка загрузки типов активности'
});
}
export function fetchTimeSlotScopes(client = apiClient) {
return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' });
}
export function fetchTimeSlotDateAssignments(client = apiClient) {
return client.get('/api/admin/time-slots/date-assignments', {
errorMessage: 'Ошибка загрузки ручных сеток'
});
}
export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) {
const params = new URLSearchParams({
page: String(page ?? 0),
size: String(size ?? 25),
query: String(query || '').trim()
});
if (academicYearId) params.set('academicYearId', String(academicYearId));
return `/api/admin/academic-calendars/page?${params}`;
}
export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) {
return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), {
errorMessage: 'Ошибка загрузки календарных графиков'
});
}
export function academicCalendarsOptionsEndpoint(query, selectedId) {
const params = new URLSearchParams({ query: String(query || ''), limit: '20' });
if (selectedId) params.set('selectedId', String(selectedId));
return `/api/admin/academic-calendars/options?${params}`;
}
export function fetchCalendarOptions(query, selectedId, client = apiClient) {
return client.get(academicCalendarsOptionsEndpoint(query, selectedId), {
errorMessage: 'Ошибка поиска календарных графиков'
});
}
export function saveAcademicYearRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' })
: client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' });
}
export function deleteAcademicYearRequest(id, client = apiClient) {
return client.delete(`/api/admin/calendar/years/${id}`, {
errorMessage: 'Ошибка удаления учебного года'
});
}
export function saveCalendarRequest(payload, id, client = apiClient) {
return id
? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' })
: client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' });
}
export function deleteCalendarRequest(id, client = apiClient) {
return client.delete(`/api/admin/academic-calendars/${id}`, {
errorMessage: 'Ошибка удаления календарного графика'
});
}
export function calendarSubjectsEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/subjects`;
}
export function fetchCalendarSubjects(calendarId, client = apiClient) {
return client.get(calendarSubjectsEndpoint(calendarId), {
errorMessage: 'Ошибка загрузки дисциплин графика'
});
}
export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) {
return client.put(calendarSubjectsEndpoint(calendarId), payload, {
errorMessage: 'Ошибка сохранения дисциплин графика'
});
}
export function calendarGridEndpoint(calendarId) {
return `/api/admin/academic-calendars/${calendarId}/grid`;
}
export function fetchCalendarGrid(calendarId, client = apiClient) {
return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' });
}
export function saveCalendarGridRequest(calendarId, rows, client = apiClient) {
return client.put(calendarGridEndpoint(calendarId), rows, {
errorMessage: 'Ошибка сохранения сетки'
});
}
export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) {
return client.delete(`/api/admin/time-slots/date-assignments/${id}`, {
errorMessage: 'Ошибка удаления ручной сетки'
});
}
// --- Подписи ---
export function calendarOptionLabel(calendar) {
return buildAcademicCalendarTitle(calendar) || calendar.title;
}
export function calendarOptionDescription(calendar) {
return calendar.specialtyName || '';
}
export function specialityLabel(speciality) {
const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
export function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
export function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}
export function shortDayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
}
export function formatShortDate(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
export function formatLongDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function formatDisplayDate(value) {
const [year, month, day] = String(value || '').split('-');
if (!year || !month || !day) return String(value || '');
return `${day}-${month}-${year}`;
}
export function formatDisplayDateRange(startDate, endDate) {
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
}
export function dateRangeCaption(rows) {
if (!rows.length) return '';
const dates = rows.map(row => row.date).sort();
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
}
export function semesterTypeTitle(semester) {
const type = normalizeSemesterType(semester.semesterType);
const label = SEMESTER_LABELS[type] || type || 'семестр';
return label.charAt(0).toUpperCase() + label.slice(1);
}
export function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
export function semesterOptionLabel(semesterNumber) {
const course = Math.ceil(Number(semesterNumber) / 2);
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
return `${semesterNumber} семестр · ${course} курс, ${type}`;
}
export function subjectLabel(subject) {
const code = subject.code ? `${subject.code} · ` : '';
return `${code}${subject.name || 'Без названия'}`;
}
export function subjectRowLabel(row) {
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
}
export function sortCalendarSubjectRows(rows) {
return [...(rows || [])].sort((a, b) => {
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
if (semesterDiff !== 0) return semesterDiff;
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
});
}
export function calendarSemesterCount(calendar) {
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
}
// --- Сетка годового графика ---
export function groupBy(items, keyFn) {
const grouped = new Map();
(items || []).forEach(item => {
const key = keyFn(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
return grouped;
}
export function calendarRowKey(row) {
return `${row.courseNumber}:${row.date}`;
}
export function uniqueWeekNumbers(rows) {
return Array.from(new Set((rows || []).map(row => row.weekNumber)));
}
export function minIso(a, b) {
return String(a) <= String(b) ? a : b;
}
export function maxIso(a, b) {
return String(a) >= String(b) ? a : b;
}
export function previousIsoDate(value) {
const date = parseLocalIsoDate(value);
date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
export function enumerateDates(startIso, endIso) {
const result = [];
const current = parseLocalIsoDate(startIso);
const end = parseLocalIsoDate(endIso);
while (current <= end) {
const iso = formatLocalDate(current);
const jsDay = current.getDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setDate(current.getDate() + 1);
}
return result;
}
function parseLocalIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day, 12);
if (!year || !month || !day || Number.isNaN(date.getTime())
|| date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
return date;
}
function formatLocalDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function buildDefaultGrid(calendar, activityTypes) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach(date => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(activityTypes),
activityCode: 'Т'
});
});
}
return rows;
}
export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) {
const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date)));
const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId));
const yearSemesters = [...(year?.semesters || [])]
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
if (!yearSemesters.length) {
return [{
semesterNumber: `${course * 2 - 1}-${course * 2}`,
caption: dateRangeCaption(sortedRows),
rows: sortedRows
}];
}
return yearSemesters.map((semester, index) => {
const nextSemester = yearSemesters[index + 1];
const startDate = index === 0
? minIso(calendar.academicYearStartDate, semester.startDate)
: semester.startDate;
const endDate = nextSemester
? previousIsoDate(nextSemester.startDate)
: maxIso(calendar.academicYearEndDate, semester.endDate);
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
return {
semesterNumber: course * 2 - 1 + index,
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
rows
};
});
}
// Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду.
export function gridTotalsEntries(rows, activityTypes) {
const counts = new Map();
(rows || []).forEach(row => {
const activity = activityById(row.activityTypeId, activityTypes);
const key = activity ? activity.code : '-';
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
return Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass }));
}
// Общий код активности набора ячеек: одно значение — оно само, иначе пусто.
export function commonActivityValue(rows) {
const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? ''))));
return values.length === 1 ? values[0] : '';
}
// --- Ручные сетки дат ---
export function timeAssignmentForDate(assignments, date) {
return (assignments || []).find(item => item.date === date);
}
export function automaticTimeScopeLabel(date, scopes) {
const jsDate = new Date(`${date}T00:00:00`);
const jsDay = jsDate.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay);
const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT');
const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка';
return `По правилу даты (${scopeName})`;
}
// --- Тексты диалога ячейки ---
export function dayDialogTexts(rows) {
const dates = rows.map(row => row.date).sort();
const courses = new Set(rows.map(row => String(row.courseNumber)));
const weeks = new Set(rows.map(row => String(row.weekNumber)));
const weekDays = new Set(rows.map(row => String(row.dayOfWeek)));
const isSingle = rows.length === 1;
const first = rows[0];
return {
date: isSingle
? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})`
: `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`,
course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`,
week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`,
day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек`
};
}
export function dayDialogSummaryParts(rows, activityTypes) {
if (rows.length <= 1) {
const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null;
return activity
? { kind: 'single', activity }
: { kind: 'empty' };
}
const common = commonActivityValue(rows);
const activity = common ? activityById(common, activityTypes) : null;
return activity
? { kind: 'multi', activity, count: rows.length }
: { kind: 'multi-empty', count: rows.length };
}
export function dayTooltipParts(row, activityTypes, scopes, assignments) {
const activity = activityById(row.activityTypeId, activityTypes);
// Код ячейки берётся с учётом запасного типа активности (реплика legacy:
// код ячейки — activityById(...) || activityTypes[0] || {}).
const cellActivity = activity || activityTypes[0] || {};
const code = activity?.code || cellActivity.code || row.activityCode || '-';
const assignment = timeAssignmentForDate(assignments, row.date);
const timeLabel = assignment
? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', '');
return {
code,
longDate: formatLongDate(row.date),
shortDay: shortDayLabel(row.dayOfWeek),
week: row.weekNumber,
course: row.courseNumber,
activityName: activity?.name || 'Не задана',
timeLabel,
colorClass: activity ? activityColorClass(activity) : ''
};
}
function lowercaseFirst(value) {
const text = String(value || '');
return text ? text[0].toLowerCase() + text.slice(1) : text;
}