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,212 @@
import { useEffect, useRef, useState } from 'react';
import { clearAuthState, storeAuthState } from '../../auth-session.js';
import { initializeTelemetry } from '../../telemetry.js';
import {
LOGIN_ERROR_MESSAGES,
requestLogin,
validateLogin
} from './login-service.js';
import { FormAlert } from '../shared/ui/FormAlert.jsx';
void initializeTelemetry();
const EMPTY_CREDENTIALS = Object.freeze({ password: '', username: '' });
function BrandIcon() {
return (
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
<path d="M12 20L18 26L28 14" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
);
}
function UserIcon() {
return (
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
function LockIcon() {
return (
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
function EyeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function Spinner() {
return (
<svg className="spinner" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" strokeDasharray="31.4 31.4" strokeLinecap="round" />
</svg>
);
}
export function LoginApp() {
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
const [errors, setErrors] = useState({});
const [alert, setAlert] = useState(null);
const [loading, setLoading] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const [rippleVisible, setRippleVisible] = useState(false);
const redirectTimer = useRef(null);
useEffect(() => () => {
if (redirectTimer.current) {
window.clearTimeout(redirectTimer.current);
}
}, []);
const updateField = event => {
const { name, value } = event.target;
setCredentials(current => ({ ...current, [name]: value }));
setErrors(current => ({ ...current, [name]: undefined }));
};
const submit = async event => {
event.preventDefault();
const nextErrors = validateLogin(credentials);
setErrors(nextErrors);
setAlert(null);
if (Object.keys(nextErrors).length > 0) {
return;
}
setLoading(true);
try {
const result = await requestLogin(credentials);
if (!result.ok) {
setAlert({ message: result.message, type: 'error' });
return;
}
clearAuthState();
const session = storeAuthState(result.data);
if (!session) {
setAlert({ message: LOGIN_ERROR_MESSAGES.invalidResponse, type: 'error' });
return;
}
setAlert({ message: 'Вход выполнен успешно!', type: 'success' });
redirectTimer.current = window.setTimeout(() => {
window.location.assign(session.redirect || '/');
}, 400);
} catch (error) {
setAlert({ message: LOGIN_ERROR_MESSAGES.connection, type: 'error' });
} finally {
setLoading(false);
}
};
const showRipple = () => {
setRippleVisible(false);
window.requestAnimationFrame(() => setRippleVisible(true));
};
return (
<>
<div className="background" aria-hidden="true">
<div className="shape shape-1" />
<div className="shape shape-2" />
<div className="shape shape-3" />
</div>
<main className="login-container">
<div className="login-card">
<header className="login-header">
<div className="logo"><BrandIcon /></div>
<h1>Добро пожаловать</h1>
<p className="subtitle">Войдите в свой аккаунт</p>
</header>
<form id="login-form" noValidate onSubmit={submit}>
<div className={`form-group stagger-1${errors.username ? ' has-error shake' : ''}`}>
<label htmlFor="username">Имя пользователя</label>
<div className="input-wrapper">
<UserIcon />
<input
type="text"
id="username"
name="username"
value={credentials.username}
onChange={updateField}
placeholder="Введите имя пользователя"
autoComplete="username"
aria-invalid={Boolean(errors.username)}
aria-describedby="username-error"
required
/>
</div>
<span className="error-message" id="username-error">{errors.username || ''}</span>
</div>
<div className={`form-group stagger-2${errors.password ? ' has-error shake' : ''}`}>
<label htmlFor="password">Пароль</label>
<div className="input-wrapper">
<LockIcon />
<input
type={passwordVisible ? 'text' : 'password'}
id="password"
name="password"
value={credentials.password}
onChange={updateField}
placeholder="Введите пароль"
autoComplete="current-password"
aria-invalid={Boolean(errors.password)}
aria-describedby="password-error"
required
/>
<button
type="button"
className="btn btn-icon-md btn-secondary toggle-password"
id="toggle-password"
aria-label={passwordVisible ? 'Скрыть пароль' : 'Показать пароль'}
aria-pressed={passwordVisible}
onClick={() => setPasswordVisible(visible => !visible)}
>
<EyeIcon />
</button>
</div>
<span className="error-message" id="password-error">{errors.password || ''}</span>
</div>
<FormAlert alert={alert} className="stagger-3" id="form-alert" />
<button
type="submit"
className="btn btn-lg btn-primary btn-submit stagger-4"
id="btn-submit"
disabled={loading}
onPointerDown={showRipple}
>
{loading ? <Spinner /> : <span className="btn-text">Войти</span>}
{rippleVisible && <span className="ripple" onAnimationEnd={() => setRippleVisible(false)} />}
</button>
</form>
</div>
</main>
</>
);
}