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:
@@ -1,285 +0,0 @@
|
||||
import {
|
||||
clearAuthState,
|
||||
fetchWithAuth,
|
||||
logoutSession,
|
||||
restoreSession
|
||||
} from '../auth-session.js';
|
||||
import { AsyncCombobox } from '../admin/js/async-combobox.js';
|
||||
import { readUrlState, writeUrlState } from '../admin/js/url-state.js';
|
||||
import { renderScheduleOverview, renderScheduleOverviewState } from '../schedule-overview.js';
|
||||
import { trackUxEvent } from '../telemetry.js';
|
||||
|
||||
(async () => {
|
||||
const session = await restoreSession();
|
||||
if (!session || session.role !== 'STUDENT') {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
const groupSelect = document.getElementById('group-select');
|
||||
const grid = document.getElementById('schedule-grid');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
const weekLabel = document.getElementById('week-label');
|
||||
const datePicker = document.getElementById('date-picker');
|
||||
const scheduleOverview = document.getElementById('schedule-overview');
|
||||
const pageState = readUrlState({ studentGroup: '', studentDate: '' });
|
||||
let weekStart = startOfWeek(pageState.studentDate ? parseIsoDate(pageState.studentDate) : new Date());
|
||||
let groupCombobox = null;
|
||||
|
||||
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await logoutSession();
|
||||
window.location.replace('/');
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
|
||||
|
||||
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
|
||||
document.getElementById('today-week').addEventListener('click', goToCurrentWeek);
|
||||
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
|
||||
datePicker.addEventListener('change', () => {
|
||||
if (datePicker.value) {
|
||||
weekStart = startOfWeek(parseIsoDate(datePicker.value));
|
||||
loadSchedule();
|
||||
}
|
||||
});
|
||||
groupSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('studentGroupId', groupSelect.value);
|
||||
writeUrlState({ studentGroup: groupSelect.value });
|
||||
loadSchedule();
|
||||
loadScheduleOverview();
|
||||
trackUxEvent('student_group_selected', { groupId: groupSelect.value });
|
||||
});
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
await loadGroups();
|
||||
await Promise.all([loadSchedule(), loadScheduleOverview()]);
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const savedGroupId = pageState.studentGroup || localStorage.getItem('studentGroupId') || '';
|
||||
const groups = await fetchGroupOptions('', savedGroupId);
|
||||
if (!groups.length) {
|
||||
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
|
||||
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Нет доступных групп' });
|
||||
return;
|
||||
}
|
||||
const selected = groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
|
||||
groupSelect.innerHTML = `<option value="${escapeHtml(selected.id)}" selected>${escapeHtml(selected.name)}</option>`;
|
||||
groupSelect.value = String(selected.id);
|
||||
localStorage.setItem('studentGroupId', groupSelect.value);
|
||||
writeUrlState({ studentGroup: groupSelect.value });
|
||||
groupCombobox = new AsyncCombobox(groupSelect, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите группу по названию или специальности',
|
||||
emptyLabel: 'Подходящие группы не найдены',
|
||||
load: fetchGroupOptions,
|
||||
optionDescription: group => [
|
||||
group.specialtyCode,
|
||||
group.specialtyProfileName,
|
||||
group.course ? `${group.course} курс` : ''
|
||||
].filter(Boolean).join(' · ')
|
||||
});
|
||||
} catch (error) {
|
||||
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
|
||||
showStatus(error.message, true);
|
||||
renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: error.message, onRetry: init });
|
||||
}
|
||||
}
|
||||
|
||||
function fetchGroupOptions(query = '', selectedId = '') {
|
||||
const params = new URLSearchParams({ query, limit: '20' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return fetchJson(`/api/groups/options?${params}`);
|
||||
}
|
||||
|
||||
function goToCurrentWeek() {
|
||||
weekStart = startOfWeek(new Date());
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
async function loadScheduleOverview() {
|
||||
const groupId = groupSelect.value;
|
||||
if (!groupId) {
|
||||
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Выберите группу, чтобы увидеть ближайшие занятия' });
|
||||
return;
|
||||
}
|
||||
renderScheduleOverviewState(scheduleOverview, { kind: 'loading' });
|
||||
const now = new Date();
|
||||
const startDate = toIsoDate(now);
|
||||
const endDate = toIsoDate(addDays(now, 14));
|
||||
try {
|
||||
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
|
||||
const overview = renderScheduleOverview(scheduleOverview, lessons, {
|
||||
role: 'student',
|
||||
now,
|
||||
onGoToday: goToCurrentWeek
|
||||
});
|
||||
trackUxEvent('student_schedule_overview_loaded', { ...overview, groupId });
|
||||
} catch (error) {
|
||||
renderScheduleOverviewState(scheduleOverview, {
|
||||
kind: 'error',
|
||||
message: error.message,
|
||||
onRetry: loadScheduleOverview
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
const groupId = groupSelect.value;
|
||||
const startDate = toIsoDate(weekStart);
|
||||
const endDate = toIsoDate(addDays(weekStart, 6));
|
||||
datePicker.value = startDate;
|
||||
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
|
||||
writeUrlState({ studentDate: startDate });
|
||||
|
||||
renderEmptyWeek('Загрузка...');
|
||||
if (!groupId) {
|
||||
showStatus('Выберите группу', false);
|
||||
renderEmptyWeek('Нет выбранной группы');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
|
||||
renderWeek(lessons);
|
||||
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
|
||||
trackUxEvent('student_schedule_loaded', { lessons: lessons.length, startDate, groupId });
|
||||
} catch (error) {
|
||||
renderEmptyWeek('Ошибка загрузки');
|
||||
showStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWeek(lessons) {
|
||||
const lessonsByDate = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
|
||||
lessonsByDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
|
||||
grid.innerHTML = daysOfWeek().map(date => {
|
||||
const key = toIsoDate(date);
|
||||
const dayLessons = lessonsByDate.get(key) || [];
|
||||
return renderDay(date, dayLessons);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEmptyWeek(message) {
|
||||
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
|
||||
}
|
||||
|
||||
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
|
||||
return `
|
||||
<article class="day">
|
||||
<div class="day-head">
|
||||
<div class="day-name">${escapeHtml(dayName(date))}</div>
|
||||
<div class="day-date">${escapeHtml(formatDate(date))}</div>
|
||||
</div>
|
||||
<div class="lesson-list">
|
||||
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLesson(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 `
|
||||
<div class="lesson">
|
||||
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
|
||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
||||
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
|
||||
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
weekStart = addDays(weekStart, delta * 7);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function daysOfWeek() {
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetchWithAuth(url);
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
window.location.replace('/');
|
||||
throw new Error('Требуется повторный вход в систему');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message;
|
||||
statusLine.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function startOfWeek(date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay() || 7;
|
||||
copy.setDate(copy.getDate() - day + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-').map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : new Date();
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function dayName(date) {
|
||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -9,47 +9,8 @@
|
||||
<link rel="stylesheet" href="../ui-foundation.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Расписание студента</h1>
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="student-root"></div>
|
||||
|
||||
<section class="toolbar">
|
||||
<select class="select" id="group-select" aria-label="Группа">
|
||||
<option value="">Загрузка групп...</option>
|
||||
</select>
|
||||
<div class="week-nav">
|
||||
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
<section class="schedule-overview" id="schedule-overview" aria-live="polite"></section>
|
||||
<div class="status" id="status-line"></div>
|
||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
<script type="module" src="/react/student.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user