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,99 @@
import { clearAuthState, fetchWithAuth } from '../../auth-session.js';
const DEFAULT_API_ERROR = 'Не удалось выполнить запрос';
const UNAUTHORIZED_ERROR = 'Требуется повторный вход в систему';
export class ApiRequestError extends Error {
constructor(message, { data = null, status = 0 } = {}) {
super(message);
this.name = 'ApiRequestError';
this.data = data;
this.status = status;
}
}
export async function requestJson(
endpoint,
{ errorMessage = DEFAULT_API_ERROR, headers: initialHeaders, json, ...options } = {},
{
fetchImpl = fetchWithAuth,
onUnauthorized = () => window.location.replace('/')
} = {}
) {
if (typeof endpoint !== 'string' || !endpoint.startsWith('/api/')) {
throw new ApiRequestError('Разрешены только same-origin запросы к API');
}
const headers = new Headers(initialHeaders || {});
headers.set('Accept', 'application/json');
let body = options.body;
if (json !== undefined) {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(json);
}
const response = await fetchImpl(endpoint, {
...options,
body,
headers
});
const data = await readResponseData(response);
if (response.status === 401) {
clearAuthState();
onUnauthorized();
throw new ApiRequestError(UNAUTHORIZED_ERROR, { data, status: response.status });
}
if (!response.ok) {
throw new ApiRequestError(errorMessage, { data, status: response.status });
}
return data;
}
export const apiClient = Object.freeze({
delete: (endpoint, options = {}, dependencies) => requestJson(
endpoint,
{ ...options, method: 'DELETE' },
dependencies
),
get: (endpoint, options = {}, dependencies) => requestJson(
endpoint,
{ ...options, method: 'GET' },
dependencies
),
post: (endpoint, json, options = {}, dependencies) => requestJson(
endpoint,
{ ...options, json, method: 'POST' },
dependencies
),
put: (endpoint, json, options = {}, dependencies) => requestJson(
endpoint,
{ ...options, json, method: 'PUT' },
dependencies
)
});
async function readResponseData(response) {
if (response.status === 204) {
return null;
}
const text = await response.text();
if (!text) {
return null;
}
const contentType = response.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
return text;
}
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}

View File

@@ -0,0 +1,28 @@
import { logoutSession, restoreSession } from '../../auth-session.js';
import { resolveSessionState, SESSION_PHASES } from './session-policy.js';
const SESSION_RESTORE_ERROR = 'Не удалось восстановить сессию';
export async function loadAuthorizedSession(
allowedRoles = [],
{ restoreSessionImpl = restoreSession } = {}
) {
try {
const session = await restoreSessionImpl();
return resolveSessionState(session, allowedRoles);
} catch (error) {
return {
message: SESSION_RESTORE_ERROR,
phase: SESSION_PHASES.error,
session: null
};
}
}
export async function logoutAndRedirect({
logoutSessionImpl = logoutSession,
replaceLocation = path => window.location.replace(path)
} = {}) {
await logoutSessionImpl();
replaceLocation('/');
}

View File

@@ -0,0 +1,53 @@
export const WEEK_LENGTH_DAYS = 7;
export function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
export function parseIsoDate(value) {
const [year, month, day] = String(value || '').split('-').map(Number);
return year && month && day ? new Date(year, month - 1, day) : new Date();
}
export function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
export function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function formatIsoDate(value) {
const [year, month, day] = String(value || '').split('-');
return year && month && day ? `${day}.${month}.${year}` : '—';
}
export function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
export function trimTime(value) {
return String(value || '').slice(0, 5);
}
export function weekDates(weekStart) {
return Array.from({ length: WEEK_LENGTH_DAYS }, (_, index) => addDays(weekStart, index));
}
export function weekEndIso(weekStart) {
return toIsoDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1));
}
export function formatWeekLabel(weekStart) {
return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1))}`;
}

View File

@@ -0,0 +1,19 @@
export {
Component,
Fragment,
StrictMode,
createElement,
useEffect,
useMemo,
useRef,
useState
} from 'react';
export { createRoot } from 'react-dom/client';
export { apiClient, ApiRequestError, requestJson } from './api-client.js';
export { loadAuthorizedSession, logoutAndRedirect } from './auth-client.js';
export { resolveSessionState, SESSION_PHASES } from './session-policy.js';
export { useAuthorizedSession } from './use-authorized-session.js';
export { AppErrorBoundary } from './ui/AppErrorBoundary.jsx';
export { AsyncState } from './ui/AsyncState.jsx';
export { FormAlert } from './ui/FormAlert.jsx';

View File

@@ -0,0 +1,38 @@
export const SESSION_PHASES = Object.freeze({
error: 'error',
loading: 'loading',
ready: 'ready',
unauthorized: 'unauthorized'
});
export function resolveSessionState(session, allowedRoles = []) {
if (!session?.role) {
return {
message: 'Требуется повторный вход в систему',
phase: SESSION_PHASES.unauthorized,
session: null
};
}
if (allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
return {
message: 'У вас нет доступа к этому разделу',
phase: SESSION_PHASES.unauthorized,
session: null
};
}
return {
message: '',
phase: SESSION_PHASES.ready,
session
};
}
export function loadingSessionState() {
return {
message: 'Проверяем сессию…',
phase: SESSION_PHASES.loading,
session: null
};
}

View File

@@ -0,0 +1,22 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AppErrorBoundary } from '../ui/AppErrorBoundary.jsx';
import { AsyncState } from '../ui/AsyncState.jsx';
import { FormAlert } from '../ui/FormAlert.jsx';
export function renderAsyncState(props) {
return renderToStaticMarkup(<AsyncState {...props} />);
}
export function renderFormAlert(props) {
return renderToStaticMarkup(<FormAlert {...props} />);
}
export function renderErrorBoundaryContent() {
return renderToStaticMarkup(
<AppErrorBoundary>
<p>Рабочее содержимое</p>
</AppErrorBoundary>
);
}

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>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useMemo, useState } from 'react';
import { loadAuthorizedSession } from './auth-client.js';
import { loadingSessionState, SESSION_PHASES } from './session-policy.js';
export function useAuthorizedSession(allowedRoles = [], { redirectOnUnauthorized = true } = {}) {
const roleKey = useMemo(
() => [...allowedRoles].sort().join('|'),
[allowedRoles]
);
const normalizedRoles = useMemo(
() => roleKey ? roleKey.split('|') : [],
[roleKey]
);
const [state, setState] = useState(loadingSessionState);
useEffect(() => {
let active = true;
setState(loadingSessionState());
void loadAuthorizedSession(normalizedRoles).then(result => {
if (!active) return;
setState(result);
if (redirectOnUnauthorized && result.phase === SESSION_PHASES.unauthorized) {
window.location.replace('/');
}
});
return () => {
active = false;
};
}, [normalizedRoles, redirectOnUnauthorized]);
return state;
}