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,28 @@
import { Component } from 'react';
import { AsyncState } from './AsyncState.jsx';
export class AppErrorBoundary extends Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
if (this.state.failed) {
return (
<main className="ui-fatal-state">
<AsyncState
kind="error"
title="Интерфейс временно недоступен"
message="Обновите страницу, чтобы восстановить работу"
onRetry={() => window.location.reload()}
/>
</main>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useId, useRef, useState } from 'react';
const SEARCH_DEBOUNCE_MS = 320;
export function AsyncCombobox({
allowClear = true,
ariaLabel = 'Поисковый список',
emptyLabel = 'Ничего не найдено',
load,
onSelect,
optionDescription = () => '',
optionLabel = item => item.name,
optionValue = item => item.id,
placeholder = 'Начните вводить название',
selectedLabel = '',
selectedValue = ''
}) {
const rootRef = useRef(null);
const inputRef = useRef(null);
const debounceTimer = useRef(null);
const requestNumber = useRef(0);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const listId = useId();
useEffect(() => () => {
window.clearTimeout(debounceTimer.current);
requestNumber.current += 1;
}, []);
useEffect(() => {
if (!open) return undefined;
const handlePointerDown = event => {
if (rootRef.current && !rootRef.current.contains(event.target)) {
close();
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
});
async function search(nextQuery) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setOpen(true);
setLoading(true);
setError('');
try {
const result = await load(String(nextQuery || '').trim(), selectedValue || '');
if (requestNumber.current !== currentRequest) return;
setItems(Array.isArray(result) ? result : []);
setLoading(false);
} catch {
if (requestNumber.current !== currentRequest) return;
setError('Не удалось выполнить поиск');
setLoading(false);
}
}
function close() {
window.clearTimeout(debounceTimer.current);
requestNumber.current += 1;
setOpen(false);
setQuery('');
setItems([]);
setError('');
setLoading(false);
}
function choose(item) {
onSelect(item);
close();
}
function clear() {
onSelect(null);
setQuery('');
inputRef.current?.focus();
void search('');
}
function handleFocus() {
setQuery(selectedLabel);
void search(selectedLabel);
}
function handleInput(event) {
const value = event.target.value;
setQuery(value);
window.clearTimeout(debounceTimer.current);
debounceTimer.current = window.setTimeout(() => {
void search(value);
}, SEARCH_DEBOUNCE_MS);
}
function handleInputKeydown(event) {
if (event.key === 'Escape') {
close();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
rootRef.current?.querySelector('[role="option"]')?.focus();
}
}
function handleMenuKeydown(event) {
const options = Array.from(rootRef.current?.querySelectorAll('[role="option"]') || []);
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
close();
inputRef.current?.focus();
} else if (event.key === 'ArrowDown' && current >= 0) {
event.preventDefault();
options[Math.min(current + 1, options.length - 1)]?.focus();
} else if (event.key === 'ArrowUp' && current >= 0) {
event.preventDefault();
(current === 0 ? inputRef.current : options[current - 1])?.focus();
}
}
return (
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
<div className="async-combobox-control">
<input
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-label={ariaLabel}
autoComplete="off"
placeholder={placeholder}
value={open ? query : selectedLabel}
onChange={handleInput}
onFocus={handleFocus}
onKeyDown={handleInputKeydown}
ref={inputRef}
/>
{allowClear && (
<button type="button" className="async-combobox-clear" aria-label="Очистить выбор" onClick={clear}>×</button>
)}
</div>
<div className="async-combobox-menu" role="listbox" id={listId} hidden={!open} onKeyDown={handleMenuKeydown}>
{open && (
loading ? (
<div className="async-combobox-state"><span className="state-spinner" aria-hidden="true" />Поиск...</div>
) : error ? (
<div className="async-combobox-state is-error">{error}</div>
) : items.length === 0 ? (
<div className="async-combobox-state">{emptyLabel}</div>
) : items.map(item => {
const value = String(optionValue(item));
const description = optionDescription(item);
return (
<button
key={value}
type="button"
role="option"
className="async-combobox-option"
aria-selected={String(selectedValue) === value}
onClick={() => choose(item)}
>
<strong>{optionLabel(item)}</strong>
{description ? <small>{description}</small> : null}
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
const DEFAULT_COPY = Object.freeze({
empty: {
message: 'Здесь пока нет данных',
title: 'Пусто'
},
error: {
message: 'Попробуйте повторить запрос',
title: 'Не удалось загрузить данные'
},
loading: {
message: 'Это займёт несколько секунд',
title: 'Загружаем данные'
}
});
export function AsyncState({ kind = 'loading', message, onRetry, title }) {
const copy = DEFAULT_COPY[kind] || DEFAULT_COPY.loading;
const isError = kind === 'error';
return (
<section
className={`ui-state ui-state--${kind}`}
role={isError ? 'alert' : 'status'}
aria-live={isError ? 'assertive' : 'polite'}
>
<span className="ui-state__signal" aria-hidden="true" />
<div className="ui-state__content">
<h2 className="ui-state__title">{title || copy.title}</h2>
<p className="ui-state__message">{message || copy.message}</p>
</div>
{isError && onRetry && (
<button type="button" className="btn btn-md btn-secondary" onClick={onRetry}>
Повторить
</button>
)}
</section>
);
}

View File

@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
export function ConfirmDialog({
title = 'Подтвердите действие',
message = '',
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
danger = false,
onConfirm,
onClose
}) {
const confirmRef = useRef(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEffect(() => {
const handleKeydown = event => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [onClose]);
return (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={onClose}
>
×
</button>
</header>
<p id="project-dialog-message">{message}</p>
<footer>
{cancelLabel && (
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</footer>
</section>
</div>
);
}
export function AlertDialog({ title, message, confirmLabel = 'Понятно', onClose }) {
return (
<ConfirmDialog
cancelLabel={null}
confirmLabel={confirmLabel}
message={message}
title={title}
onClose={onClose}
onConfirm={onClose}
/>
);
}
// Реплика legacy promptAction: диалог с обязательным текстовым полем.
// onConfirm получает trimmed-значение; пустое обязательное поле показывает
// ошибку внутри диалога, как в admin/js/dialog.js.
export function PromptDialog({
title = 'Подтвердите действие',
message = '',
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
danger = false,
input = {},
onConfirm,
onClose
}) {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeydown = event => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [onClose]);
function confirm() {
const trimmed = value.trim();
if (input.required && !trimmed) {
setError(input.requiredMessage || 'Заполните поле');
inputRef.current?.focus();
return;
}
onConfirm(trimmed);
}
return (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={onClose}
>
×
</button>
</header>
<p id="project-dialog-message">{message}</p>
<label className="project-dialog-field">
<span>{input.label || 'Комментарий'}</span>
<textarea
ref={inputRef}
rows="4"
maxLength={Number(input.maxlength || 2000)}
placeholder={input.placeholder || ''}
value={value}
onChange={event => {
setValue(event.target.value);
setError('');
}}
/>
</label>
<div className="project-dialog-error" role="alert">{error}</div>
<footer>
{cancelLabel && (
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
{cancelLabel}
</button>
)}
<button
type="button"
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={confirm}
>
{confirmLabel}
</button>
</footer>
</section>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function FormAlert({ alert, className = '', id }) {
const tone = alert?.type === 'success' ? 'success' : 'error';
const classes = ['form-alert', className, alert ? tone : ''].filter(Boolean).join(' ');
return (
<div className={classes} id={id} role="alert" aria-live="polite">
{alert?.message || ''}
</div>
);
}

View File

@@ -0,0 +1,57 @@
const PAGE_SIZES = [25, 50, 100];
export function Pagination({
id,
page = 0,
size = 25,
totalItems = 0,
totalPages = 0,
label = 'записей',
onPage,
onSize
}) {
const total = Number(totalItems || 0);
const pages = Math.max(Number(totalPages || 0), total ? 1 : 0);
const current = Number(page || 0);
const pageSize = Number(size || 25);
const start = total ? current * pageSize + 1 : 0;
const end = Math.min((current + 1) * pageSize, total);
return (
<div className="pagination" id={id}>
<div className="pagination-summary" aria-live="polite">
Показано {start}–{end} из {total} {label}
</div>
<div className="pagination-actions">
<label className="pagination-size">
<span>На странице</span>
<select
value={pageSize}
onChange={event => onSize?.(Number(event.target.value))}
>
{PAGE_SIZES.map(value => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-sm btn-secondary"
disabled={current <= 0}
onClick={() => onPage?.(current - 1)}
>
Назад
</button>
<span className="pagination-page">{pages ? current + 1 : 0} / {pages}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
disabled={current >= pages - 1}
onClick={() => onPage?.(current + 1)}
>
Далее
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import {
buildScheduleOverviewModel,
formatLongDate,
lessonMeta,
lessonStart,
pluralLessons,
relativeDate,
trimTime
} from '../../../schedule-overview.js';
export function ScheduleOverview({ lessons, now = new Date(), onGoToday, role = 'student' }) {
const { todayIso, todayLessons, nextLesson } = buildScheduleOverviewModel(lessons, now);
return (
<section className="schedule-overview" aria-live="polite">
<header className="schedule-overview-head">
<div>
<span className="schedule-overview-kicker">Быстрый обзор</span>
<h2>{formatLongDate(now)}</h2>
</div>
<button className="btn btn-md btn-secondary" type="button" onClick={onGoToday}>
Открыть текущую неделю
</button>
</header>
<div className="schedule-overview-grid">
<article className="schedule-overview-card is-today">
<div className="schedule-overview-card-head">
<div>
<span>Сегодня</span>
<h3>{todayLessons.length ? pluralLessons(todayLessons.length) : 'Свободный день'}</h3>
</div>
<span className="schedule-overview-count">{todayLessons.length}</span>
</div>
<div className="schedule-overview-list">
{todayLessons.length > 0
? todayLessons.map((lesson, index) => (
<CompactLesson
key={`${lesson.date}-${trimTime(lesson.startTime)}-${index}`}
lesson={lesson}
role={role}
/>
))
: <p className="schedule-overview-empty">На сегодня занятий нет.</p>}
</div>
</article>
<article className="schedule-overview-card is-next">
<div className="schedule-overview-card-head">
<div>
<span>Следующая пара</span>
<h3>{nextLesson ? relativeDate(nextLesson.date, todayIso) : 'Пока ничего не запланировано'}</h3>
</div>
{nextLesson && (
<span className="schedule-overview-badge">
{lessonStart(nextLesson) <= now ? 'Идёт сейчас' : trimTime(nextLesson.startTime)}
</span>
)}
</div>
{nextLesson
? <NextLessonCard lesson={nextLesson} role={role} />
: <p className="schedule-overview-empty">В ближайшие 14 дней занятий нет.</p>}
</article>
</div>
</section>
);
}
function CompactLesson({ lesson, role }) {
return (
<div className="schedule-overview-lesson">
<time>{trimTime(lesson.startTime)}</time>
<div>
<strong>{lesson.subjectName || 'Занятие'}</strong>
<small>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</small>
</div>
</div>
);
}
function NextLessonCard({ lesson, role }) {
return (
<div className="schedule-overview-next">
<div className="schedule-overview-next-time">
<strong>{trimTime(lesson.startTime)}</strong>
<span>до {trimTime(lesson.endTime)}</span>
</div>
<div>
<h4>{lesson.subjectName || 'Занятие'}</h4>
<p>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</p>
{lesson.lessonTypeName && <span className="schedule-overview-type">{lesson.lessonTypeName}</span>}
</div>
</div>
);
}