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

@@ -6,7 +6,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const frontendRoot = fileURLToPath(new URL('..', import.meta.url));
const sourceExtensions = new Set(['.html', '.js', '.mjs', '.css']);
const sourceExtensions = new Set(['.html', '.js', '.jsx', '.mjs', '.css']);
async function sourceFiles(directory = frontendRoot) {
const result = [];
@@ -77,8 +77,9 @@ test('Docker-образ включает локальный telemetry bundle и
const dockerfile = await readFile(join(frontendRoot, 'Dockerfile'), 'utf8');
const telemetry = await readFile(join(frontendRoot, 'telemetry.js'), 'utf8');
assert.match(dockerfile, /npm ci/);
assert.match(dockerfile, /npm run build:vendor/);
assert.match(dockerfile, /scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /npm run build/);
assert.match(dockerfile, /scripts\/build-react\.mjs scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/react\//);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
assert.match(dockerfile, /magistr-security\.conf/);
for (const asset of ['ui-foundation.css', 'schedule-overview.css', 'schedule-overview.js']) {
@@ -93,10 +94,13 @@ test('Docker-образ включает локальный telemetry bundle и
test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => {
const files = [
'admin/js/main.js',
'admin/settings/js/main.js',
'admin/settings/js/views/database.js',
'admin/js/views/teacher-requests.js'
'react/admin/AdminApp.jsx',
'react/admin/tabs/TeacherRequestsTab.jsx',
'react/admin/tabs/DepartmentWorkspaceTab.jsx',
'react/admin/tabs/AuditoriumWorkloadTab.jsx',
'react/admin/tabs/AcademicCalendarTab.jsx',
'react/settings/SettingsApp.jsx',
'react/settings/DatabaseTab.jsx'
];
for (const relativePath of files) {
@@ -112,22 +116,22 @@ test('сообщения об ошибках не вставляют текст
});
test('пароли скрыты и помечены для менеджера паролей', async () => {
const users = await readFile(join(frontendRoot, 'admin/views/users.html'), 'utf8');
const database = await readFile(join(frontendRoot, 'admin/settings/views/database.html'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'admin/js/views/teacher-requests.js'), 'utf8');
const users = await readFile(join(frontendRoot, 'react/admin/tabs/UsersTab.jsx'), 'utf8');
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'react/admin/tabs/TeacherRequestsTab.jsx'), 'utf8');
assert.match(users, /id="new-password"[^>]*type="password"|type="password"[^>]*id="new-password"/);
assert.match(users, /id="new-password"[^>]*autocomplete="new-password"/);
assert.match(database, /id="tenant-password"[^>]*type="password"|type="password"[^>]*id="tenant-password"/);
assert.match(database, /id="tenant-password"[^>]*autocomplete="new-password"/);
assert.match(teacherRequests, /type="password"[^>]*class="teacher-request-password"[^>]*autocomplete="new-password"/);
assert.match(users, /id="new-password"[^>]*autoComplete="new-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?type="password"|type="password"[\s\S]*?id="tenant-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?autoComplete="new-password"|autoComplete="new-password"[\s\S]*?id="tenant-password"/);
assert.match(teacherRequests, /type="password"[^>]*className="teacher-request-password"[^>]*autoComplete="new-password"/);
});
test('tenant UI и production-логи соблюдают русский языковой регламент', async () => {
const database = await readFile(join(frontendRoot, 'admin/settings/js/views/database.js'), 'utf8');
assert.doesNotMatch(database, /\bOnline\b|\bOffline\b/);
assert.match(database, /Доступно/);
assert.match(database, /Недоступно/);
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
assert.doesNotMatch(databaseTab, /\bOnline\b|\bOffline\b/);
assert.match(databaseTab, /Доступно/);
assert.match(databaseTab, /Недоступно/);
const backendRoot = join(frontendRoot, '..', 'backend', 'src', 'main', 'java', 'com', 'magistr', 'app', 'config', 'tenant');
const logSources = await Promise.all([
@@ -144,7 +148,7 @@ test('tenant UI и production-логи соблюдают русский язы
);
const frontendSources = (await sourceCorpus())
.filter(({ path }) => extname(path) === '.js')
.filter(({ path }) => ['.js', '.jsx'].includes(extname(path)))
.map(({ text }) => text)
.join('\n');
assert.doesNotMatch(frontendSources, /console\.(?:log|info|warn|error)\([^\n]*(?:Failed|Error)/);
@@ -166,10 +170,10 @@ test('единая матрица ролей согласует admin и setting
assert.equal(educationOffice.defaultSettingsTab, 'time-slots');
assert.equal(capabilitiesForRole('DEPARTMENT').settingsTabs.length, 0);
const adminMain = await readFile(join(frontendRoot, 'admin/js/main.js'), 'utf8');
const settingsMain = await readFile(join(frontendRoot, 'admin/settings/js/main.js'), 'utf8');
assert.match(adminMain, /from '\.\/role-capabilities\.js'/);
assert.match(settingsMain, /from '\.\.\/\.\.\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminMain, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsMain, /const ROLE_TABS/);
const adminService = await readFile(join(frontendRoot, 'react/admin/admin-service.js'), 'utf8');
const settingsApp = await readFile(join(frontendRoot, 'react/settings/SettingsApp.jsx'), 'utf8');
assert.match(adminService, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.match(settingsApp, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminService, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsApp, /const ROLE_TABS/);
});