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,95 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
import { fetchScheduleSemesters, fetchScheduleVersions } from './schedule-versions-service.js';
// Срез «Анализ качества расписания» (этап 5, волна 5): чистая логика и контракты
// API, перенесённые из legacy admin/js/views/schedule-quality.js без изменения поведения.
// --- Диапазоны оценки ---
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
// --- Фильтрация проблем ---
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
// --- Подписи ---
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}
export function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
export function qualitySemesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatQualityDate(semester.startDate)}—${formatQualityDate(semester.endDate)}`;
}
export function formatQualityDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
export function lessonWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function problemTotalLabel(analysis) {
return analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
}
// --- Контракты API ---
export function scheduleQualityEndpoint(semesterId, versionId) {
return `/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`;
}
export async function fetchScheduleQualityAnalysis(semesterId, versionId, client = apiClient) {
return client.get(scheduleQualityEndpoint(semesterId, versionId), { errorMessage: 'Не удалось выполнить анализ качества.' });
}
export { fetchScheduleSemesters, fetchScheduleVersions, formatLocalDate };