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

@@ -5,7 +5,7 @@ import test from 'node:test';
import {
scheduleSlotActionLabel,
shouldMarkRuleFormDirty
} from '../admin/js/views/schedule.js';
} from '../react/admin/schedule-service.js';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
@@ -26,57 +26,66 @@ test('конструктор считает изменениями только
});
test('поисковые списки просмотра расписаний загружаются свёрнутыми', async () => {
const scheduleView = await readFrontendFile('admin/js/views/schedule-view.js');
const [scheduleViewTab, combobox] = await Promise.all([
readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx'),
readFrontendFile('react/shared/ui/AsyncCombobox.jsx')
]);
assert.match(scheduleView, /combo\.preload\(''\)/);
assert.doesNotMatch(scheduleView, /combo\.search\(''\)/);
assert.match(scheduleViewTab, /<AsyncCombobox/);
assert.doesNotMatch(scheduleViewTab, /search\(''\)/);
assert.match(combobox, /const \[open, setOpen\] = useState\(false\)/);
assert.match(combobox, /onFocus=\{handleFocus\}/);
});
test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => {
const files = await Promise.all([
readFrontendFile('admin/index.html'),
readFrontendFile('admin/js/main.js'),
readFrontendFile('react/admin/AdminApp.jsx'),
readFrontendFile('admin/settings/index.html'),
readFrontendFile('admin/settings/js/main.js')
readFrontendFile('react/settings/SettingsApp.jsx')
]);
files.forEach(content => assert.doesNotMatch(content, /menu-toggle/));
});
test('заголовок вкладки соответствует конструктору правил', async () => {
const mainScript = await readFrontendFile('admin/js/main.js');
assert.match(mainScript, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
const adminService = await readFrontendFile('react/admin/admin-service.js');
assert.match(adminService, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
});
test('основная кнопка показа находится в заголовке блока параметров', async () => {
const scheduleView = await readFrontendFile('admin/views/schedule-view.html');
const controlHeaderIndex = scheduleView.indexOf('schedule-view-control-header');
const controlGridIndex = scheduleView.indexOf('schedule-view-control-grid');
const loadButtonIndex = scheduleView.indexOf('id="schedule-view-load"');
const scheduleViewTab = await readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx');
const controlHeaderIndex = scheduleViewTab.indexOf('schedule-view-control-header');
const controlGridIndex = scheduleViewTab.indexOf('schedule-view-control-grid');
const loadButtonIndex = scheduleViewTab.indexOf('id="schedule-view-load"');
assert.ok(controlHeaderIndex >= 0);
assert.ok(loadButtonIndex > controlHeaderIndex);
assert.ok(loadButtonIndex < controlGridIndex);
assert.equal(scheduleView.match(/id="schedule-view-load"/g)?.length, 1);
assert.equal(scheduleViewTab.match(/id="schedule-view-load"/g)?.length, 1);
});
test('пустое состояние расписания содержит собственную кнопку обновления', async () => {
const scheduleScript = await readFrontendFile('admin/js/views/schedule-view.js');
const [scheduleViewService, scheduleViewTab] = await Promise.all([
readFrontendFile('react/admin/schedule-view-service.js'),
readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx')
]);
assert.match(scheduleScript, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
assert.match(scheduleScript, /data-schedule-view-reload/);
assert.match(scheduleScript, /closest\('\[data-schedule-view-reload\]'\)[\s\S]*?loadData\(\)/);
assert.match(scheduleScript, /setTableState\(message,\s*message === SCHEDULE_REFRESH_PROMPT\)/);
assert.match(scheduleViewService, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
assert.match(scheduleViewTab, /data-schedule-view-reload/);
assert.match(scheduleViewTab, /data-schedule-view-reload=""[\s\S]*?void loadData\(\)/);
assert.match(scheduleViewTab, /actionable: message === SCHEDULE_REFRESH_PROMPT/);
assert.match(scheduleViewTab, /renderStateRow\(tableState\.message,\s*tableState\.actionable\)/);
});
test('действия слотов имеют размер соседнего поля', async () => {
const [components, scheduleScript] = await Promise.all([
const [components, scheduleTab] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/js/views/schedule.js')
readFrontendFile('react/admin/tabs/ScheduleTab.jsx')
]);
assert.match(components, /\.schedule-slot-remove\s*\{[^}]*min-width:\s*125px[^}]*min-height:\s*44px/s);
assert.match(scheduleScript, /class="btn btn-md \$\{actionClass\} schedule-slot-remove"/);
assert.match(scheduleTab, /className=\{`btn btn-md \$\{actionClass\} schedule-slot-remove`\}/);
});
test('все варианты системных кнопок имеют видимую обводку', async () => {
@@ -89,59 +98,57 @@ test('все варианты системных кнопок имеют вид
});
test('подсказка календарной сетки получает позицию напрямую от курсора', async () => {
const calendarScript = await readFrontendFile('admin/js/views/academic-calendar.js');
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
assert.match(calendarScript, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
assert.match(calendarScript, /\{ duration: 1, fill: 'forwards' \}/);
assert.doesNotMatch(calendarScript, /\.style\.(?:left|top)\s*=/);
assert.match(calendarTab, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
assert.match(calendarTab, /\{ duration: 1, fill: 'forwards' \}/);
assert.doesNotMatch(calendarTab, /\.style\.(?:left|top)\s*=/);
});
test('светлая тема задаёт контрастные пункты и заголовки левого меню', async () => {
const [layout, index] = await Promise.all([
const [layout, workspace, adminService] = await Promise.all([
readFrontendFile('admin/css/layout.css'),
readFrontendFile('admin/index.html')
readFrontendFile('react/admin/AdminApp.jsx'),
readFrontendFile('react/admin/admin-service.js')
]);
assert.match(layout, /\[data-theme="light"\] \.sidebar \.nav-item\s*\{[^}]*color:\s*#334155/s);
assert.match(layout, /\[data-theme="light"\] \.sidebar-section-title\s*\{[^}]*color:\s*#64748b !important/s);
assert.match(index, />Расписание<\/div>/);
assert.match(workspace, /sidebar-section-title/);
assert.match(adminService, /title: 'Расписание'/);
});
test('таблица календарных графиков не содержит отдельный столбец названия', async () => {
const [calendarView, calendarScript] = await Promise.all([
readFrontendFile('admin/views/academic-calendar.html'),
readFrontendFile('admin/js/views/academic-calendar.js')
]);
const bodyIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const tableStart = calendarView.lastIndexOf('<table>', bodyIndex);
const tableEnd = calendarView.indexOf('</table>', bodyIndex);
const calendarTable = calendarView.slice(tableStart, tableEnd);
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
const bodyIndex = calendarTab.indexOf('id="academic-calendars-tbody"');
const tableStart = calendarTab.lastIndexOf('<table>', bodyIndex);
const tableEnd = calendarTab.indexOf('</table>', bodyIndex);
const calendarTable = calendarTab.slice(tableStart, tableEnd);
assert.doesNotMatch(calendarTable, /<th>Название<\/th>/);
assert.match(calendarTable, /colspan="6"/);
assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/);
assert.match(calendarTable, /colSpan=\{6\}/);
assert.doesNotMatch(calendarTab, /title:\s*buildCalendarTitleFromForm/);
});
test('конструктор правил показывает готовую компоновку без скачков колонок', async () => {
const [scheduleView, scheduleScript, components] = await Promise.all([
readFrontendFile('admin/views/schedule.html'),
readFrontendFile('admin/js/views/schedule.js'),
const [scheduleTab, components] = await Promise.all([
readFrontendFile('react/admin/tabs/ScheduleTab.jsx'),
readFrontendFile('admin/css/components.css')
]);
assert.match(scheduleView, /id="schedule-initial-state"/);
assert.match(scheduleView, /id="schedule-initial-content"[^>]*class="schedule-initial-content is-loading"|class="schedule-initial-content is-loading"[^>]*id="schedule-initial-content"/);
assert.match(scheduleView, /<table id="schedule-table">\s*<colgroup>/s);
assert.match(scheduleScript, /finally\s*\{[\s\S]*initialContent\?\.classList\.remove\('is-loading'\)/);
assert.match(scheduleTab, /id="schedule-initial-state"/);
assert.match(scheduleTab, /schedule-initial-content\$\{initialPhase === 'loading' \? ' is-loading' : ''\}/);
assert.match(scheduleTab, /<table id="schedule-table">\s*<colgroup>/s);
assert.match(scheduleTab, /finally\s*\{[\s\S]*?setInitialPhase\('ready'\)/);
assert.match(components, /#schedule-table\s*\{[^}]*table-layout:\s*fixed/s);
assert.match(components, /#schedule-table tbody tr\s*\{[^}]*animation:\s*none/s);
});
test('форма учебного года расположена после рабочих блоков календарного графика', async () => {
const calendarView = await readFrontendFile('admin/views/academic-calendar.html');
const calendarsRegistryIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const academicYearBuilderIndex = calendarView.indexOf('academic-year-builder-card');
const managePanelEndIndex = calendarView.indexOf('</section>', academicYearBuilderIndex);
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
const calendarsRegistryIndex = calendarTab.indexOf('id="academic-calendars-tbody"');
const academicYearBuilderIndex = calendarTab.indexOf('academic-year-builder-card');
const managePanelEndIndex = calendarTab.indexOf('</section>', academicYearBuilderIndex);
assert.ok(calendarsRegistryIndex >= 0);
assert.ok(academicYearBuilderIndex > calendarsRegistryIndex);
@@ -149,13 +156,14 @@ test('форма учебного года расположена после р
});
test('поисковые и диалоговые списки имеют размытие и светлую тему', async () => {
const [components, absences, absenceView] = await Promise.all([
const [components, absences, absenceTab] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/css/teacher-absences.css'),
readFrontendFile('admin/views/teacher-absences.html')
readFrontendFile('react/admin/tabs/TeacherAbsencesTab.jsx')
]);
assert.match(absenceView, /id="teacher-absence-teacher"[^>]*data-native-select="true"/);
assert.match(absenceTab, /AsyncCombobox/);
assert.match(absenceTab, /placeholder="Найдите преподавателя по ФИО"/);
assert.match(components, /\.async-combobox-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);
assert.match(components, /\[data-theme="light"\] \.async-combobox-menu\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.96\)/s);
assert.match(absences, /\.teacher-dialogue-toolbar \.custom-select-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);