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:
99
frontend/react/shared/api-client.js
Normal file
99
frontend/react/shared/api-client.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user