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:
326
frontend/react/student/StudentApp.jsx
Normal file
326
frontend/react/student/StudentApp.jsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.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 { AsyncCombobox } from '../shared/ui/AsyncCombobox.jsx';
|
||||
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
||||
import { buildScheduleOverviewModel } from '../../schedule-overview.js';
|
||||
import { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
|
||||
import { WeekGrid } from './WeekGrid.jsx';
|
||||
import {
|
||||
addDays,
|
||||
buildWeekStatusMessage,
|
||||
fetchGroupOptions,
|
||||
fetchSchedule,
|
||||
formatWeekLabel,
|
||||
groupOptionDescription,
|
||||
GROUP_STORAGE_KEY,
|
||||
OVERVIEW_DAYS,
|
||||
parseIsoDate,
|
||||
resolveSelectedGroup,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
weekEndIso
|
||||
} from './student-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
|
||||
export function StudentApp() {
|
||||
const sessionState = useAuthorizedSession(['STUDENT']);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||
}, []);
|
||||
|
||||
if (sessionState.phase === SESSION_PHASES.loading) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="loading" title="Расписание студента" message="Проверяем сессию…" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionState.phase !== SESSION_PHASES.ready) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <StudentWorkspace />;
|
||||
}
|
||||
|
||||
function StudentWorkspace() {
|
||||
const [initialUrlState] = useState(() => readUrlState({ studentGroup: '', studentDate: '' }));
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(
|
||||
initialUrlState.studentDate ? parseIsoDate(initialUrlState.studentDate) : new Date()
|
||||
));
|
||||
const [group, setGroup] = useState(null);
|
||||
const [groupsState, setGroupsState] = useState({ kind: 'loading' });
|
||||
const [groupsReload, setGroupsReload] = useState(0);
|
||||
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
|
||||
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
|
||||
const [overviewReload, setOverviewReload] = useState(0);
|
||||
|
||||
const weekStartIso = toIsoDate(weekStart);
|
||||
const groupId = group ? String(group.id) : '';
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
setGroupsState({ kind: 'loading' });
|
||||
const savedGroupId = initialUrlState.studentGroup
|
||||
|| localStorage.getItem(GROUP_STORAGE_KEY)
|
||||
|| '';
|
||||
try {
|
||||
const groups = await fetchGroupOptions({ selectedId: savedGroupId });
|
||||
if (!active) return;
|
||||
if (!groups.length) {
|
||||
setGroupsState({ kind: 'empty' });
|
||||
setOverviewState({ kind: 'empty', message: 'Нет доступных групп' });
|
||||
return;
|
||||
}
|
||||
const selected = resolveSelectedGroup(groups, savedGroupId);
|
||||
applyGroup(selected);
|
||||
setGroupsState({ kind: 'ready' });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setGroupsState({ kind: 'error' });
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [groupsReload, initialUrlState]);
|
||||
|
||||
useEffect(() => {
|
||||
writeUrlState({ studentDate: weekStartIso });
|
||||
}, [weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return undefined;
|
||||
let active = true;
|
||||
(async () => {
|
||||
setScheduleState({ kind: 'loading' });
|
||||
try {
|
||||
const lessons = await fetchSchedule({
|
||||
groupId: String(group.id),
|
||||
startDate: weekStartIso,
|
||||
endDate: weekEndIso(weekStart)
|
||||
});
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'ready', lessons });
|
||||
trackUxEvent('student_schedule_loaded', {
|
||||
lessons: lessons.length,
|
||||
startDate: weekStartIso,
|
||||
groupId: String(group.id)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'error' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [group, weekStart, weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return undefined;
|
||||
let active = true;
|
||||
(async () => {
|
||||
setOverviewState({ kind: 'loading' });
|
||||
const now = new Date();
|
||||
try {
|
||||
const lessons = await fetchSchedule({
|
||||
groupId: String(group.id),
|
||||
startDate: toIsoDate(now),
|
||||
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS))
|
||||
});
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'ready', lessons, now });
|
||||
const overview = buildScheduleOverviewModel(lessons, now);
|
||||
trackUxEvent('student_schedule_overview_loaded', {
|
||||
todayCount: overview.todayLessons.length,
|
||||
hasNext: Boolean(overview.nextLesson),
|
||||
groupId: String(group.id)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [group, overviewReload]);
|
||||
|
||||
function applyGroup(nextGroup) {
|
||||
setGroup(nextGroup);
|
||||
localStorage.setItem(GROUP_STORAGE_KEY, String(nextGroup.id));
|
||||
writeUrlState({ studentGroup: String(nextGroup.id) });
|
||||
}
|
||||
|
||||
function handleGroupSelect(nextGroup) {
|
||||
if (!nextGroup) return;
|
||||
applyGroup(nextGroup);
|
||||
trackUxEvent('student_group_selected', { groupId: String(nextGroup.id) });
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
setWeekStart(current => addDays(current, delta * 7));
|
||||
}
|
||||
|
||||
function goToCurrentWeek() {
|
||||
setWeekStart(startOfWeek(new Date()));
|
||||
}
|
||||
|
||||
function handleDateChange(event) {
|
||||
if (event.target.value) {
|
||||
setWeekStart(startOfWeek(parseIsoDate(event.target.value)));
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="shell">
|
||||
<header className="topbar">
|
||||
<div className="title">
|
||||
<h1>Расписание студента</h1>
|
||||
<span>{formatWeekLabel(weekStart)}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn btn-icon-md btn-secondary"
|
||||
type="button"
|
||||
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>
|
||||
<a className="btn btn-danger-subtle" href="/" onClick={handleLogout}>Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="toolbar">
|
||||
{groupsState.kind === 'ready' && group ? (
|
||||
<AsyncCombobox
|
||||
allowClear={false}
|
||||
ariaLabel="Группа"
|
||||
emptyLabel="Подходящие группы не найдены"
|
||||
placeholder="Найдите группу по названию или специальности"
|
||||
load={(query, selectedId) => fetchGroupOptions({ query, selectedId })}
|
||||
onSelect={handleGroupSelect}
|
||||
optionDescription={groupOptionDescription}
|
||||
optionLabel={option => option.name}
|
||||
optionValue={option => option.id}
|
||||
selectedLabel={group.name}
|
||||
selectedValue={groupId}
|
||||
/>
|
||||
) : (
|
||||
<select className="select" aria-label="Группа" disabled>
|
||||
<option value="">{groupSelectPlaceholder(groupsState.kind)}</option>
|
||||
</select>
|
||||
)}
|
||||
<div className="week-nav">
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Предыдущая неделя" onClick={() => moveWeek(-1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="btn btn-md btn-primary" type="button" onClick={goToCurrentWeek}>Сегодня</button>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Следующая неделя" onClick={() => moveWeek(1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
aria-label="Дата недели"
|
||||
value={weekStartIso}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{overviewState.kind === 'ready' ? (
|
||||
<ScheduleOverview
|
||||
lessons={overviewState.lessons}
|
||||
now={overviewState.now}
|
||||
onGoToday={goToCurrentWeek}
|
||||
role="student"
|
||||
/>
|
||||
) : (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
{overviewState.kind === 'empty' ? (
|
||||
<AsyncState kind="empty" title="Быстрый обзор" message={overviewState.message || 'В ближайшие дни занятий нет'} />
|
||||
) : overviewState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Быстрый обзор"
|
||||
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
|
||||
onRetry={() => setOverviewReload(reload => reload + 1)}
|
||||
/>
|
||||
) : (
|
||||
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
|
||||
{statusMessage(scheduleState, groupsState)}
|
||||
</div>
|
||||
|
||||
{groupsState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Группы"
|
||||
message="Не удалось загрузить группы"
|
||||
onRetry={() => setGroupsReload(reload => reload + 1)}
|
||||
/>
|
||||
) : scheduleState.kind === 'ready' ? (
|
||||
<WeekGrid weekStart={weekStart} lessons={scheduleState.lessons} />
|
||||
) : scheduleState.kind === 'error' ? (
|
||||
<WeekGrid weekStart={weekStart} lessons={[]} emptyMessage="Ошибка загрузки" />
|
||||
) : (
|
||||
<WeekGrid
|
||||
weekStart={weekStart}
|
||||
lessons={[]}
|
||||
emptyMessage={groupId ? 'Загрузка...' : 'Нет выбранной группы'}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function groupSelectPlaceholder(kind) {
|
||||
if (kind === 'empty') return 'Группы не найдены';
|
||||
if (kind === 'error') return 'Ошибка загрузки групп';
|
||||
return 'Загрузка групп...';
|
||||
}
|
||||
|
||||
function statusMessage(scheduleState, groupsState) {
|
||||
if (groupsState.kind === 'error') return 'Не удалось загрузить группы';
|
||||
if (groupsState.kind === 'empty') return 'Выберите группу';
|
||||
if (scheduleState.kind === 'error') return 'Не удалось загрузить расписание';
|
||||
if (scheduleState.kind === 'ready') return buildWeekStatusMessage(scheduleState.lessons);
|
||||
return 'Загрузка расписания...';
|
||||
}
|
||||
58
frontend/react/student/WeekGrid.jsx
Normal file
58
frontend/react/student/WeekGrid.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
buildLessonChips,
|
||||
dayName,
|
||||
formatDate,
|
||||
groupLessonsByDate,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates
|
||||
} from './student-service.js';
|
||||
|
||||
export function WeekGrid({ emptyMessage = 'Занятий нет', lessons = [], weekStart }) {
|
||||
const lessonsByDate = groupLessonsByDate(lessons);
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
{weekDates(weekStart).map(date => (
|
||||
<DayCard
|
||||
key={toIsoDate(date)}
|
||||
date={date}
|
||||
lessons={lessonsByDate.get(toIsoDate(date)) || []}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DayCard({ date, lessons, emptyMessage }) {
|
||||
return (
|
||||
<article className="day">
|
||||
<div className="day-head">
|
||||
<div className="day-name">{dayName(date)}</div>
|
||||
<div className="day-date">{formatDate(date)}</div>
|
||||
</div>
|
||||
<div className="lesson-list">
|
||||
{lessons.length > 0
|
||||
? lessons.map((lesson, index) => (
|
||||
<LessonCard key={`${lesson.date}-${trimTime(lesson.startTime)}-${index}`} lesson={lesson} />
|
||||
))
|
||||
: <div className="empty">{emptyMessage}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCard({ lesson }) {
|
||||
return (
|
||||
<div className="lesson">
|
||||
<div className="lesson-time">{trimTime(lesson.startTime)} - {trimTime(lesson.endTime)}</div>
|
||||
<div className="lesson-title">{lesson.subjectName}</div>
|
||||
<div className="lesson-meta">
|
||||
{buildLessonChips(lesson).map((chip, index) => (
|
||||
<span key={index} className="chip">{chip}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/react/student/main.jsx
Normal file
22
frontend/react/student/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
|
||||
import { StudentApp } from './StudentApp.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const rootElement = document.getElementById('student-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент кабинета студента');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<StudentApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
73
frontend/react/student/student-service.js
Normal file
73
frontend/react/student/student-service.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
|
||||
export {
|
||||
addDays,
|
||||
dayName,
|
||||
formatDate,
|
||||
formatWeekLabel,
|
||||
parseIsoDate,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates,
|
||||
weekEndIso,
|
||||
WEEK_LENGTH_DAYS
|
||||
} from '../shared/dates.js';
|
||||
|
||||
export const OVERVIEW_DAYS = 14;
|
||||
export const GROUP_STORAGE_KEY = 'studentGroupId';
|
||||
|
||||
export function groupLessonsByDate(lessons) {
|
||||
const byDate = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
|
||||
byDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
export function buildLessonChips(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return [
|
||||
lesson.lessonTypeName,
|
||||
subgroups,
|
||||
lesson.lessonFormat,
|
||||
lesson.teacherName,
|
||||
lesson.classroomName,
|
||||
groups
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildWeekStatusMessage(lessons) {
|
||||
const count = Array.isArray(lessons) ? lessons.length : 0;
|
||||
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
|
||||
}
|
||||
|
||||
export function resolveSelectedGroup(groups, savedGroupId = '') {
|
||||
if (!Array.isArray(groups) || groups.length === 0) return null;
|
||||
return groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
|
||||
}
|
||||
|
||||
export function groupOptionDescription(group) {
|
||||
return [
|
||||
group.specialtyCode,
|
||||
group.specialtyProfileName,
|
||||
group.course ? `${group.course} курс` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export async function fetchGroupOptions({ query = '', selectedId = '' } = {}, client = apiClient) {
|
||||
const params = new URLSearchParams({ query, limit: '20' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return client.get(`/api/groups/options?${params}`, { errorMessage: 'Не удалось загрузить группы' });
|
||||
}
|
||||
|
||||
export async function fetchSchedule({ groupId, startDate, endDate }, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`,
|
||||
{ errorMessage: 'Не удалось загрузить расписание' }
|
||||
);
|
||||
}
|
||||
23
frontend/react/student/testing/render-student.jsx
Normal file
23
frontend/react/student/testing/render-student.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
|
||||
import { ScheduleOverview } from '../../shared/ui/ScheduleOverview.jsx';
|
||||
import { WeekGrid } from '../WeekGrid.jsx';
|
||||
import { parseIsoDate, startOfWeek } from '../student-service.js';
|
||||
|
||||
export function renderWeekGrid(props) {
|
||||
return renderToStaticMarkup(createElement(WeekGrid, props));
|
||||
}
|
||||
|
||||
export function renderScheduleOverview(props) {
|
||||
return renderToStaticMarkup(createElement(ScheduleOverview, props));
|
||||
}
|
||||
|
||||
export function renderAsyncCombobox(props) {
|
||||
return renderToStaticMarkup(createElement(AsyncCombobox, props));
|
||||
}
|
||||
|
||||
export function weekStartOf(isoDate) {
|
||||
return startOfWeek(parseIsoDate(isoDate));
|
||||
}
|
||||
Reference in New Issue
Block a user