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,622 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY,
SCHEDULE_VERSION_STORAGE_KEY,
SCHEDULE_YEAR_STORAGE_KEY,
createScheduleDraftVersion,
draftBasisText,
fetchScheduleSemesters,
fetchScheduleVersionDiff,
fetchScheduleVersionHistory,
fetchScheduleVersionValidation,
fetchScheduleVersions,
formatLocalDate,
formatVersionDateTime,
historyActionLabel,
publishValidationSummary,
ruleChangeTypeLabel,
runScheduleVersionAction,
splitScheduleVersions,
versionSemesterLabel
} from '../schedule-versions-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Загружаем контур версий…', tone: '' });
export function ScheduleVersionsTab() {
const [semesters, setSemesters] = useState([]);
const [baseLoaded, setBaseLoaded] = useState(false);
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [history, setHistory] = useState([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [diffState, setDiffState] = useState(null);
const [modal, setModal] = useState(null);
const [modalValue, setModalValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, loading };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const { published, drafts, archive } = useMemo(() => splitScheduleVersions(versions), [versions]);
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
setBaseLoaded(true);
if (!loadedSemesters.length) {
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'error' });
return;
}
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, selected.academicYearId);
await loadWorkspace(selected.semesterId);
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
// Escape закрывает окно операции (в legacy — слушатель на document)
useEffect(() => {
if (!modal) return;
function handleKeydown(event) {
if (event.key === 'Escape') closeModal();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [modal]);
async function loadWorkspace(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId || latestRef.current.loading) return;
setLoading(true);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, nextSemesterId);
setStatus({ message: 'Собираем версии, правила и журнал публикаций…', tone: 'loading' });
try {
const [nextVersions, nextHistory] = await Promise.all([
fetchScheduleVersions(nextSemesterId),
fetchScheduleVersionHistory(nextSemesterId)
]);
setVersions(nextVersions);
setHistory(nextHistory);
setLoaded(true);
trackUxEvent('версии_расписания_загружены', { semesterId: nextSemesterId, total: nextVersions.length });
setStatus({ message: 'Контур версий синхронизирован.', tone: 'success' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
} finally {
setLoading(false);
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, nextYearId);
void loadWorkspace(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadWorkspace(nextSemesterId);
}
function handleVersionAction(version, action) {
if (action === 'edit') {
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule';
return;
}
if (action === 'quality') {
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule-quality';
return;
}
if (action === 'diff') {
void loadDiff(version);
return;
}
if (action === 'publish') {
void openPublishModal(version);
return;
}
if (action === 'restore') {
openRestoreModal(version);
}
}
async function loadDiff(version) {
setDiffState({ phase: 'loading' });
try {
const diff = await fetchScheduleVersionDiff(version.id);
setDiffState({ phase: 'ready', diff });
} catch (error) {
setDiffState({ phase: 'error', message: error.message });
}
}
function openCreateModal() {
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus({ message: 'Проверяем целостность черновика перед публикацией…', tone: 'loading' });
try {
const [validation, diff] = await Promise.all([
fetchScheduleVersionValidation(version.id),
fetchScheduleVersionDiff(version.id)
]);
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
submit: 'Опубликовать',
summary: publishValidationSummary(validation, diff)
});
setStatus(validation.valid
? { message: 'Черновик готов к публикации.', tone: 'success' }
: { message: 'Черновик требует исправлений.', tone: 'error' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось проверить черновик.', tone: 'error' });
}
}
function openRestoreModal(version) {
openModal({
mode: 'restore',
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
submit: 'Восстановить'
});
}
function openModal(config) {
setModal(config);
setModalValue(config.value || '');
setSubmitting(false);
}
function closeModal() {
setModal(null);
setModalValue('');
setSubmitting(false);
}
async function handleSubmit(event) {
event.preventDefault();
if (!modal || modal.valid === false || submitting) return;
const value = modalValue.trim();
if (!value) {
setStatus({
message: modal.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.',
tone: 'error'
});
return;
}
setSubmitting(true);
try {
if (modal.mode === 'create') {
const created = await createScheduleDraftVersion({
semesterId: Number(latestRef.current.semesterId),
name: value,
basedOnVersionId: modal.version?.id || null
});
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(created.id));
setStatus({ message: `Черновик «${created.name}» создан.`, tone: 'success' });
} else {
await runScheduleVersionAction(modal.version.id, modal.mode, value);
setStatus({
message: modal.mode === 'publish'
? 'Версия опубликована атомарно.'
: 'Предыдущая версия восстановлена.',
tone: 'success'
});
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus({ message: error.message || 'Операцию не удалось выполнить.', tone: 'error' });
setSubmitting(false);
}
}
const yearOptions = !baseLoaded
? <option value="">Загрузка годов…</option>
: (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>);
const semesterOptions = !baseLoaded
? <option value="">Сначала выберите год</option>
: (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{versionSemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>);
return (
<section className="version-studio" aria-labelledby="version-studio-title">
<header className="version-hero">
<div>
<span className="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div className="version-hero-controls">
<div className="version-hero-period-grid">
<div>
<label htmlFor="version-year">Учебный год</label>
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
</div>
<div>
<label htmlFor="version-semester">Рабочий семестр</label>
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
</div>
</div>
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
Новый черновик
</button>
</div>
</header>
<div
className="version-status"
id="version-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<section className="version-current" id="version-current" aria-label="Опубликованная версия">
{loaded ? (published ? (
<>
<div className="version-live-mark">LIVE</div>
<div>
<span className="version-eyebrow">Сейчас видят пользователи</span>
<h3>{published.name} · версия {published.versionNumber}</h3>
<p>{published.changeReason || 'Причина первой публикации не указана'}</p>
<div className="version-stats">
<span>{published.ruleCount} правил</span>
<span>{published.slotCount} слотов</span>
<span>{formatVersionDateTime(published.publishedAt)}</span>
</div>
</div>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(published, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(published, 'quality')}
>
Проверить качество
</button>
</div>
</>
) : (
<>
<div className="version-live-mark">ПУСТО</div>
<div>
<h3>Опубликованной версии нет</h3>
<p>Создайте первый черновик и опубликуйте его после проверки.</p>
</div>
</>
)) : null}
</section>
<div className="version-workbench">
<section className="version-drafts-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">{drafts.length}</span>
</div>
<div className="version-draft-list" id="version-drafts">
{loaded ? (drafts.length ? drafts.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<div className="version-card-top">
<div>
<span className="version-status-chip">Черновик · v{version.versionNumber}</span>
<h4>{version.name}</h4>
</div>
</div>
<p>{draftBasisText(version)}</p>
<small>
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(version, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'publish')}
>
Опубликовать
</button>
</div>
</article>
)) : (
<div className="version-empty">
<span>+</span>
<h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
)) : null}
</div>
</section>
<aside className="version-diff-panel" id="version-diff">
{diffState === null ? (
<div className="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
) : diffState.phase === 'loading' ? (
<div className="version-empty">
<span>…</span>
<h3>Сравниваем расписания</h3>
<p>Генерируем занятия обеих версий по всему семестру.</p>
</div>
) : diffState.phase === 'error' ? (
<div className="version-empty">
<span>!</span>
<h3>Сравнение недоступно</h3>
<p>{diffState.message}</p>
</div>
) : (
<VersionDiff diff={diffState.diff} />
)}
</aside>
</div>
<section className="version-archive-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div className="version-archive-list" id="version-archive">
{loaded ? (archive.length ? archive.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<span className="version-status-chip">Архив · v{version.versionNumber}</span>
<h4>{version.name}</h4>
<p>{version.changeReason || 'Причина не указана'}</p>
<small>{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => handleVersionAction(version, 'restore')}
>
Восстановить
</button>
</div>
</article>
)) : <p className="version-empty-note">Предыдущих публикаций пока нет.</p>) : null}
</div>
</section>
<section className="version-history-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div className="version-history" id="version-history">
{loaded ? (history.length ? history.map((item, index) => (
<article className="version-history-row" key={index}>
<time>{formatVersionDateTime(item.createdAt)}</time>
<div>
<strong>
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}
</strong>
<p>{item.reason || 'Без комментария'}</p>
</div>
<span className="version-status-chip">{item.actorName || 'Система'}</span>
</article>
)) : <p className="version-empty-note">Действия ещё не зафиксированы.</p>) : null}
</div>
</section>
{modal ? (
<div
className="version-modal"
id="version-modal"
onClick={event => {
if (event.target === event.currentTarget) closeModal();
}}
>
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button
type="button"
className="btn btn-icon-md btn-ghost version-modal-close"
id="version-modal-close"
aria-label="Закрыть окно"
onClick={closeModal}
>
&times;
</button>
<span className="version-eyebrow" id="version-modal-kicker">{modal.kicker}</span>
<h3 id="version-modal-title">{modal.title}</h3>
<p id="version-modal-copy">{modal.copy}</p>
<form id="version-modal-form" onSubmit={handleSubmit}>
<label htmlFor="version-modal-value" id="version-modal-label">{modal.label}</label>
<textarea
id="version-modal-value"
rows="3"
maxLength="2000"
required
autoFocus
value={modalValue}
onChange={event => setModalValue(event.target.value)}
></textarea>
<div className="version-modal-summary" id="version-modal-summary" hidden={!modal.summary}>
{modal.summary || ''}
</div>
<div className="version-modal-actions">
<button type="button" className="btn btn-md btn-ghost" id="version-modal-cancel" onClick={closeModal}>
Отмена
</button>
<button
type="submit"
className="btn btn-md btn-primary"
id="version-modal-submit"
disabled={modal.valid === false || submitting}
>
{submitting ? 'Выполняем…' : modal.submit}
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
function VersionDiff({ diff }) {
return (
<>
<span className="version-eyebrow">{diff.baseVersionLabel} → {diff.targetVersionLabel}</span>
<h3>Что изменится после публикации</h3>
<div className="version-diff-score">
<div><strong>+{diff.addedLessonCount}</strong><span>занятий</span></div>
<div><strong>{diff.changedLessonCount}</strong><span>изменено</span></div>
<div><strong>−{diff.removedLessonCount}</strong><span>удалено</span></div>
</div>
<p className="version-diff-caption">
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
</p>
<div className="version-change-list">
{(diff.ruleChanges || []).length
? diff.ruleChanges.map((change, index) => (
<article className="version-change" key={index} data-type={change.type}>
<strong>{ruleChangeTypeLabel(change.type)} · {change.subjectName}</strong>
<span>{(change.groupNames || []).join(', ') || 'Группы не указаны'}</span>
{change.beforeSummary ? <span>Было: {change.beforeSummary}</span> : null}
{change.afterSummary ? <span>Стало: {change.afterSummary}</span> : null}
</article>
))
: (
<div className="version-empty">
<h3>Правила совпадают</h3>
<p>Структурных изменений между версиями нет.</p>
</div>
)}
</div>
</>
);
}