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,209 @@
import { useEffect, useState } from 'react';
import { SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
import { logoutAndRedirect } from '../shared/auth-client.js';
import { SESSION_PHASES } from '../shared/session-policy.js';
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
import { AsyncState } from '../shared/ui/AsyncState.jsx';
import { DatabaseTab } from './DatabaseTab.jsx';
import { EduFormsTab } from './EduFormsTab.jsx';
import { GeneralTab } from './GeneralTab.jsx';
import { TimeSlotsTab } from './TimeSlotsTab.jsx';
import {
defaultSettingsTabForRole,
settingsTabsForRole
} from './settings-service.js';
const THEME_STORAGE_KEY = 'theme';
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
const TAB_ICONS = Object.freeze({
database: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg>
),
'edu-forms': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
),
general: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
'time-slots': (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 16 14" />
<path d="M4 4l3 3" />
<path d="M20 4l-3 3" />
</svg>
)
});
export function SettingsApp() {
const sessionState = useAuthorizedSession(SETTINGS_ROLES);
useEffect(() => {
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
}, []);
if (sessionState.phase === SESSION_PHASES.loading) {
return <SettingsShell title="Настройки"><AsyncState kind="loading" title="Настройки" message="Проверяем сессию…" /></SettingsShell>;
}
if (sessionState.phase !== SESSION_PHASES.ready) {
return <SettingsShell title="Настройки"><AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} /></SettingsShell>;
}
return <SettingsWorkspace role={sessionState.session.role} />;
}
function SettingsShell({ title, sidebar, children }) {
const [collapsed, setCollapsed] = useState(() => (
typeof window !== 'undefined'
&& window.innerWidth > 768
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
));
function toggleSidebar() {
setCollapsed(current => {
const next = !current;
localStorage.setItem(SIDEBAR_STORAGE_KEY, next);
return next;
});
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem(THEME_STORAGE_KEY, next);
}
function handleLogout(event) {
event.preventDefault();
void logoutAndRedirect();
}
return (
<>
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
<div className="sidebar-header">
<div className="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" strokeWidth="3" strokeLinecap="round"
strokeLinejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button
type="button"
className="btn btn-icon-sm btn-ghost sidebar-close-btn"
aria-label="Скрыть панель"
onClick={toggleSidebar}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{sidebar}
<div className="sidebar-footer">
<a href="/admin/" className="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
<a href="/" className="btn-back" onClick={handleLogout}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Выйти</span>
</a>
</div>
</aside>
<div className="sidebar-overlay" />
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
<header className="topbar">
<h1>{title}</h1>
<button
type="button"
className="btn btn-icon-md btn-secondary theme-toggle"
aria-label="Переключить тему"
onClick={toggleTheme}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
</svg>
</button>
</header>
<section className="content">
{children}
</section>
</main>
</>
);
}
function SettingsWorkspace({ role }) {
const tabs = settingsTabsForRole(role);
const [activeTab, setActiveTab] = useState(() => defaultSettingsTabForRole(role) || tabs[0]?.id);
const active = tabs.find(tab => tab.id === activeTab) || tabs[0];
const sidebar = (
<nav className="sidebar-nav">
{tabs.map(tab => (
<a
key={tab.id}
href="#"
className={`nav-item${tab.id === active?.id ? ' active' : ''}`}
onClick={event => {
event.preventDefault();
setActiveTab(tab.id);
}}
>
{TAB_ICONS[tab.id]}
<span>{tab.title}</span>
</a>
))}
</nav>
);
return (
<SettingsShell sidebar={sidebar} title={active ? active.title : 'Настройки'}>
{active?.id === 'general' && <GeneralTab />}
{active?.id === 'time-slots' && <TimeSlotsTab />}
{active?.id === 'edu-forms' && <EduFormsTab />}
{active?.id === 'database' && <DatabaseTab />}
</SettingsShell>
);
}