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,124 @@
import { apiClient } from '../shared/api-client.js';
import { formatLocalDate } from '../../admin/js/utils.js';
// Срез «Контур публикации» (этап 5, волна 5): чистая логика и контракты API,
// перенесённые из legacy admin/js/views/schedule-versions.js без изменения поведения.
// --- Ключи localStorage для переходов между вкладками расписания ---
// (общие с конструктором правил и анализом качества)
export const SCHEDULE_YEAR_STORAGE_KEY = 'magistr.schedule.yearId';
export const SCHEDULE_SEMESTER_STORAGE_KEY = 'magistr.schedule.semesterId';
export const SCHEDULE_OPEN_VERSION_STORAGE_KEY = 'magistr.schedule.openVersionId';
export const SCHEDULE_OPEN_RULE_STORAGE_KEY = 'magistr.schedule.openRuleId';
export const SCHEDULE_VERSION_STORAGE_KEY = 'magistr.schedule.versionId';
export const QUALITY_YEAR_STORAGE_KEY = 'magistr.quality.yearId';
export const QUALITY_SEMESTER_STORAGE_KEY = 'magistr.quality.semesterId';
export const QUALITY_VERSION_STORAGE_KEY = 'magistr.quality.versionId';
// --- Подписи ---
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
export function versionSemesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
export function formatVersionDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
export function ruleChangeTypeLabel(type) {
return { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[type] || type;
}
export function draftBasisText(version) {
return `Основа: ${version.basedOnVersionId ? `версия #${version.basedOnVersionId}` : 'пустое расписание'}`;
}
// --- Группировка версий ---
export function splitScheduleVersions(versions) {
const list = Array.isArray(versions) ? versions : [];
return {
published: list.find(version => version.status === 'PUBLISHED') || null,
drafts: list.filter(version => version.status === 'DRAFT'),
archive: list.filter(version => version.status === 'ARCHIVED')
};
}
// --- Сводка проверки перед публикацией ---
export function publishValidationSummary(validation, diff) {
return validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. `
+ `Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
}
// --- Контракты API ---
export function scheduleVersionsEndpoint(semesterId) {
return `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`;
}
export function scheduleVersionHistoryEndpoint(semesterId) {
return `/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`;
}
export function scheduleVersionDiffEndpoint(versionId) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/diff`;
}
export function scheduleVersionValidateEndpoint(versionId) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/validate`;
}
export function scheduleVersionActionEndpoint(versionId, mode) {
return `/api/edu-office/schedule/versions/${encodeURIComponent(versionId)}/${mode}`;
}
export async function fetchScheduleSemesters(client = apiClient) {
return client.get('/api/schedule/semesters', { errorMessage: 'Не удалось загрузить семестры.' });
}
export async function fetchScheduleVersions(semesterId, client = apiClient) {
return client.get(scheduleVersionsEndpoint(semesterId), { errorMessage: 'Не удалось загрузить версии расписания.' });
}
export async function fetchScheduleVersionHistory(semesterId, client = apiClient) {
return client.get(scheduleVersionHistoryEndpoint(semesterId), { errorMessage: 'Не удалось загрузить журнал действий.' });
}
export async function fetchScheduleVersionDiff(versionId, client = apiClient) {
return client.get(scheduleVersionDiffEndpoint(versionId), { errorMessage: 'Не удалось сравнить версии расписания.' });
}
export async function fetchScheduleVersionValidation(versionId, client = apiClient) {
return client.get(scheduleVersionValidateEndpoint(versionId), { errorMessage: 'Не удалось проверить черновик.' });
}
export async function createScheduleDraftVersion(payload, client = apiClient) {
return client.post('/api/edu-office/schedule/versions', payload, { errorMessage: 'Не удалось создать черновик.' });
}
export async function runScheduleVersionAction(versionId, mode, reason, client = apiClient) {
return client.post(
scheduleVersionActionEndpoint(versionId, mode),
{ reason },
{ errorMessage: 'Операцию не удалось выполнить.' }
);
}
export { formatLocalDate };