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