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