задачи 2 и 8
This commit is contained in:
462
frontend/admin/js/views/schedule-quality.js
Normal file
462
frontend/admin/js/views/schedule-quality.js
Normal file
@@ -0,0 +1,462 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
let qualityState = null;
|
||||
|
||||
export async function initScheduleQuality() {
|
||||
const root = document.querySelector('.quality-lab');
|
||||
if (!root) return;
|
||||
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
|
||||
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
|
||||
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
|
||||
await loadSemesters();
|
||||
}
|
||||
|
||||
async function loadSemesters() {
|
||||
const select = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!select) return;
|
||||
try {
|
||||
const semesters = await api.get('/api/schedule/semesters');
|
||||
select.innerHTML = (semesters || []).map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">
|
||||
${escapeHtml(semesterLabel(semester))}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!semesters?.length) {
|
||||
select.innerHTML = '<option value="">Семестры не настроены</option>';
|
||||
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
|
||||
return;
|
||||
}
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester =>
|
||||
semester.startDate <= today && semester.endDate >= today
|
||||
);
|
||||
select.value = String(current?.id ?? semesters[0].id);
|
||||
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
|
||||
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
|
||||
select.value = savedSemesterId;
|
||||
}
|
||||
await loadVersions();
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const select = qualityState?.root.querySelector('#quality-version');
|
||||
if (!select || !semesterId) return;
|
||||
select.innerHTML = '<option value="">Загрузка версий…</option>';
|
||||
try {
|
||||
qualityState.versions = await api.get(
|
||||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||||
);
|
||||
select.innerHTML = qualityState.versions.map(version => `
|
||||
<option value="${escapeHtml(String(version.id))}">
|
||||
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!qualityState.versions.length) {
|
||||
select.innerHTML = '<option value="">Версии не созданы</option>';
|
||||
return;
|
||||
}
|
||||
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
|
||||
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
|
||||
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
|
||||
select.value = String((saved || published || qualityState.versions[0]).id);
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
|
||||
if (!semesterId) {
|
||||
qualityStatus('Выберите семестр для анализа.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!versionId) {
|
||||
qualityStatus('Выберите версию расписания для анализа.', 'warning');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
localStorage.setItem('magistr.quality.versionId', versionId);
|
||||
const button = qualityState.root.querySelector('#quality-run');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Анализируем…';
|
||||
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
|
||||
qualityState.root.querySelector('#quality-results').hidden = true;
|
||||
try {
|
||||
qualityState.analysis = await api.get(
|
||||
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
|
||||
);
|
||||
qualityState.selectedProblemId = null;
|
||||
renderAnalysis();
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
|
||||
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Выполнить анализ';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalysis() {
|
||||
const analysis = qualityState?.analysis;
|
||||
if (!analysis) return;
|
||||
qualityState.root.querySelector('#quality-results').hidden = false;
|
||||
const band = qualityBand(analysis.score);
|
||||
const orbit = qualityState.root.querySelector('#quality-score-orbit');
|
||||
orbit.dataset.band = band.code;
|
||||
qualityState.root.querySelector('#quality-score-ring-value')
|
||||
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
|
||||
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
|
||||
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
|
||||
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
|
||||
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
|
||||
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
|
||||
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
|
||||
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
|
||||
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
|
||||
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
|
||||
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
|
||||
`;
|
||||
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
|
||||
.map((metric, index) => `
|
||||
<article class="quality-metric quality-metric-${index + 1}">
|
||||
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
|
||||
<h4>${escapeHtml(metric.label)}</h4>
|
||||
<p>${escapeHtml(metric.explanation)}</p>
|
||||
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
|
||||
</article>
|
||||
`).join('');
|
||||
const totalLabel = analysis.problemsTruncated
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
|
||||
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
|
||||
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
|
||||
renderProblems();
|
||||
renderRecommendationsEmpty();
|
||||
}
|
||||
|
||||
function renderProblems() {
|
||||
const container = qualityState?.root.querySelector('#quality-problems');
|
||||
if (!container || !qualityState.analysis) return;
|
||||
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
|
||||
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
|
||||
qualityState.visibleProblems = filterQualityProblems(
|
||||
qualityState.analysis.problems || [], severity, category
|
||||
);
|
||||
if (!qualityState.visibleProblems.length) {
|
||||
container.innerHTML = `
|
||||
<div class="quality-empty-state">
|
||||
<strong>По выбранному фильтру проблем нет</strong>
|
||||
<span>Измените уровень важности или категорию.</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
|
||||
const selected = problem.id === qualityState.selectedProblemId;
|
||||
return `
|
||||
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
|
||||
<button type="button" class="quality-problem-main" data-problem-index="${index}">
|
||||
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
|
||||
<span class="quality-problem-copy">
|
||||
<strong>${escapeHtml(problem.title)}</strong>
|
||||
<span>${escapeHtml(problem.description)}</span>
|
||||
<small>
|
||||
${escapeHtml(formatDate(problem.lessonDate))}
|
||||
· ${escapeHtml(problem.subjectName || 'Занятие')}
|
||||
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
|
||||
</small>
|
||||
</span>
|
||||
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
|
||||
</button>
|
||||
<div class="quality-problem-action">
|
||||
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
|
||||
Подобрать улучшения
|
||||
</button>`
|
||||
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function handleProblemClick(event) {
|
||||
const button = event.target.closest('[data-problem-index]');
|
||||
if (!button) return;
|
||||
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
|
||||
if (!problem) return;
|
||||
qualityState.selectedProblemId = problem.id;
|
||||
renderProblems();
|
||||
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
renderRecommendationsMessage(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик остаётся изолированным'
|
||||
: 'Архивная версия доступна только для проверки',
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
|
||||
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!problem.optimizable) {
|
||||
renderRecommendationsMessage(
|
||||
'Ручное решение сохранено',
|
||||
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await loadRecommendations(problem);
|
||||
}
|
||||
|
||||
async function loadRecommendations(problem) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-loading">
|
||||
<span class="quality-loader"></span>
|
||||
<strong>Проверяем варианты</strong>
|
||||
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
|
||||
</div>
|
||||
`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
semesterId: String(qualityState.analysis.semesterId),
|
||||
versionId: String(qualityState.analysis.scheduleVersionId),
|
||||
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
|
||||
lessonDate: problem.lessonDate
|
||||
});
|
||||
const result = await api.get(
|
||||
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
|
||||
);
|
||||
renderRecommendations(problem, result);
|
||||
} catch (error) {
|
||||
renderRecommendationsMessage(
|
||||
'Не удалось подобрать варианты',
|
||||
error.message || 'Повторите попытку после обновления анализа.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecommendations(problem, result) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidates = result.candidates || [];
|
||||
if (!candidates.length) {
|
||||
renderRecommendationsMessage(
|
||||
'Свободных улучшений не найдено',
|
||||
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-head">
|
||||
<span class="quality-kicker">Проверенные варианты</span>
|
||||
<h3>${escapeHtml(problem.title)}</h3>
|
||||
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
|
||||
</div>
|
||||
<div class="quality-candidates">
|
||||
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
|
||||
</div>
|
||||
<p class="quality-recommendations-note">
|
||||
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
|
||||
</p>
|
||||
`;
|
||||
panel._qualityCandidates = candidates;
|
||||
}
|
||||
|
||||
function recommendationCard(candidate, index) {
|
||||
const impactClass = candidate.penaltyDelta > 0
|
||||
? 'is-positive'
|
||||
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
|
||||
const impact = candidate.scoreDelta > 0
|
||||
? `+${candidate.scoreDelta} к оценке`
|
||||
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
|
||||
return `
|
||||
<article class="quality-candidate ${impactClass}">
|
||||
<div class="quality-candidate-top">
|
||||
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
|
||||
<strong>${escapeHtml(candidate.label)}</strong>
|
||||
</div>
|
||||
<p>${escapeHtml(candidate.description)}</p>
|
||||
<div class="quality-candidate-impact">
|
||||
<span>${escapeHtml(impact)}</span>
|
||||
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
|
||||
</div>
|
||||
${changeList('Улучшится', candidate.improvements, 'positive')}
|
||||
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
|
||||
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
|
||||
Применить после подтверждения
|
||||
</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function changeList(label, items, tone) {
|
||||
if (!items?.length) return '';
|
||||
return `
|
||||
<div class="quality-change-list is-${tone}">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function handleRecommendationClick(event) {
|
||||
const button = event.target.closest('[data-candidate-index]');
|
||||
if (!button || qualityState?.applying) return;
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
|
||||
if (!candidate) return;
|
||||
const confirmed = window.confirm(
|
||||
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
qualityState.applying = true;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Применяем…';
|
||||
try {
|
||||
await api.post('/api/edu-office/schedule/overrides', candidate.override);
|
||||
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
|
||||
await runAnalysis();
|
||||
} catch (error) {
|
||||
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
|
||||
button.disabled = false;
|
||||
button.textContent = 'Применить после подтверждения';
|
||||
} finally {
|
||||
qualityState.applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecommendationsEmpty() {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
|
||||
renderRecommendationsMessage(
|
||||
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
|
||||
isDraft
|
||||
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
|
||||
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderRecommendationsMessage(
|
||||
'Локальные улучшения',
|
||||
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
|
||||
);
|
||||
}
|
||||
|
||||
function problemActionLabel(problem) {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
|
||||
return 'Исправляется в конструкторе';
|
||||
}
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
|
||||
return 'Только просмотр';
|
||||
}
|
||||
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
|
||||
}
|
||||
|
||||
function renderRecommendationsMessage(title, message) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-empty">
|
||||
<span class="quality-recommendations-mark">↗</span>
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
panel._qualityCandidates = [];
|
||||
}
|
||||
|
||||
function qualityStatus(message, tone = '') {
|
||||
const status = qualityState?.root.querySelector('#quality-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.dataset.tone = tone;
|
||||
}
|
||||
|
||||
export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
return (problems || []).filter(problem => {
|
||||
const severityMatches = !severity || problem.severity === severity;
|
||||
const categoryMatches = !category || problemCategory(problem.type) === category;
|
||||
return severityMatches && categoryMatches;
|
||||
});
|
||||
}
|
||||
|
||||
function problemCategory(type = '') {
|
||||
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
|
||||
if (type.includes('LOAD')) return 'LOAD';
|
||||
if (type.includes('ROOM')) return 'ROOM';
|
||||
return 'PREFERENCE';
|
||||
}
|
||||
|
||||
export function qualityBand(score) {
|
||||
if (score >= 90) {
|
||||
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
|
||||
}
|
||||
if (score >= 75) {
|
||||
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
|
||||
}
|
||||
if (score >= 55) {
|
||||
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
|
||||
}
|
||||
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
|
||||
}
|
||||
|
||||
export function formatMetricValue(metric) {
|
||||
const value = Number(metric?.value || 0);
|
||||
const formatted = Number.isInteger(value)
|
||||
? String(value)
|
||||
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
|
||||
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
|
||||
}
|
||||
|
||||
function severityLabel(severity) {
|
||||
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
|
||||
}
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'дата не указана';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function localIsoDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function problemWord(count) {
|
||||
const mod100 = count % 100;
|
||||
const mod10 = count % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
|
||||
if (mod10 === 1) return 'проблема';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
|
||||
return 'проблем';
|
||||
}
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
|
||||
}
|
||||
Reference in New Issue
Block a user