Files
Learn_System/frontend/js/exam-prep/topics.js
T

307 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ──────────────────────────────────────────────────────────────────
Topics view — two phases on one page:
list : /exam-prep/:examKey/topics
shows sections (parents) with subtopics + per-user accuracy
drill : /exam-prep/:examKey/topics/:slug
renders TaskCards for a topic-specific practice batch
────────────────────────────────────────────────────────────────── */
(async function () {
await EP.boot();
const examKey = EP.examKey;
// Parse slug from URL: /exam-prep/<key>/topics/<slug>
const slug = (() => {
const m = location.pathname.match(/\/topics\/([a-z0-9\-_]+)/i);
return m ? m[1] : null;
})();
if (slug) await renderTopicView(slug);
else await renderListView();
})();
/* ════════════════════════════════════════════════════════════════
LIST VIEW
════════════════════════════════════════════════════════════════ */
async function renderListView() {
const examKey = EP.examKey;
const main = document.getElementById('ep-main');
let payload;
try {
payload = await EP.api.listTopics(examKey);
} catch (e) {
main.innerHTML = errorHtml('Не удалось загрузить темы', e);
if (window.lucide) lucide.createIcons();
return;
}
const { sections } = payload;
if (!sections.length) {
main.innerHTML = `<div class="ep-empty">
<i data-lucide="tag"></i>
<h4>Темы не настроены</h4>
<p>Запустите тегирование: <code>node backend/scripts/tag-exam-tasks.js math9</code></p>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
main.innerHTML = `
<div class="ep-card">
<h3>Тренировка по темам</h3>
<div class="ep-card-hint">Выберите тему — модуль соберёт батч из задач этой темы. По умолчанию даём только нерешённые.</div>
</div>
<div class="tp-sections">
${sections.map(renderSection).join('')}
</div>
`;
if (window.lucide) lucide.createIcons();
}
function renderSection(s) {
const subtopics = s.subtopics
.filter(st => st.total > 0)
.sort((a, b) => b.total - a.total);
return `
<div class="ep-card tp-section">
<div class="tp-section-head">
<h3>${escapeHtml(s.title)}</h3>
<span class="tp-section-meta">${s.solved}/${s.total}</span>
</div>
<div class="tp-section-bar">
<div class="ep-bar"><div class="ep-bar-fill" style="width:${s.total ? Math.round(s.solved/s.total*100) : 0}%"></div></div>
</div>
<div class="tp-grid">
${subtopics.map(renderSubtopicCard).join('')}
</div>
</div>`;
}
function renderSubtopicCard(st) {
const accuracyClass = st.accuracy == null ? '' : st.accuracy >= 70 ? 'tp-good' : st.accuracy >= 40 ? 'tp-warn' : 'tp-bad';
const accuracyText = st.accuracy == null ? '—' : st.accuracy + '%';
const solvedPct = st.total ? Math.round((st.solved / st.total) * 100) : 0;
const href = `/exam-prep/${EP.examKey}/topics/${encodeURIComponent(st.slug)}`;
return `
<a class="tp-card" href="${href}">
<div class="tp-card-head">
<span class="tp-card-title">${escapeHtml(st.title)}</span>
<span class="tp-card-count">${st.total}</span>
</div>
<div class="tp-card-progress">
<div class="ep-bar"><div class="ep-bar-fill" style="width:${solvedPct}%"></div></div>
<span>${st.solved} решено</span>
</div>
<div class="tp-card-foot">
<span class="tp-card-accuracy ${accuracyClass}" title="Точность по этой теме">${accuracyText}</span>
<span class="tp-card-cta">Прокачать <i data-lucide="arrow-right"></i></span>
</div>
</a>`;
}
/* ════════════════════════════════════════════════════════════════
TOPIC PRACTICE VIEW
════════════════════════════════════════════════════════════════ */
async function renderTopicView(slug) {
const examKey = EP.examKey;
const main = document.getElementById('ep-main');
let count = readPersistedCount(examKey);
let excludeSolved = true;
let batch = null;
let finalized = false;
const results = new Map();
await loadBatch();
async function loadBatch() {
finalized = false;
results.clear();
main.innerHTML = `
<div class="tp-back-bar">
<a class="tp-back" href="/exam-prep/${examKey}/topics">
<i data-lucide="arrow-left"></i> Все темы
</a>
</div>
<div class="pr-controls" id="tp-controls">${controlsHTML()}</div>
<div class="ep-empty">
<i data-lucide="loader-circle"></i><h4>Загрузка…</h4>
</div>`;
if (window.lucide) lucide.createIcons();
wireControls();
try {
batch = await EP.api.getTopicTasks(examKey, slug, { count, exclude_solved: excludeSolved ? 1 : 0 });
} catch (e) {
main.querySelector('.ep-empty').outerHTML = errorHtml('Не удалось загрузить тему', e);
if (window.lucide) lucide.createIcons();
return;
}
// Update header title with topic name
const subEl = document.getElementById('ep-sub');
if (subEl && batch.topic) subEl.textContent = `Тема: ${batch.topic.title} · ${batch.tasks.length} задач`;
if (!batch.tasks.length) {
main.querySelector('.ep-empty').outerHTML = `
<div class="ep-empty">
<i data-lucide="party-popper"></i>
<h4>В этой теме всё решено!</h4>
<p>Снимите фильтр «только нерешённые», чтобы повторить.</p>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
renderBatch();
}
function controlsHTML() {
const opts = [5, 10, 15, 20].map(n => `<option value="${n}" ${n === count ? 'selected' : ''}>${n} задач</option>`).join('');
return `
<div class="pr-controls-row">
<label class="tp-checkbox">
<input type="checkbox" id="tp-exclude" ${excludeSolved ? 'checked' : ''} />
<span>Только нерешённые</span>
</label>
<div class="pr-count-pick">
<label>Размер:</label>
<select class="pr-count-select" id="tp-count">${opts}</select>
</div>
<button class="ep-btn ep-btn-primary pr-restart" id="tp-restart">
<i data-lucide="rotate-cw"></i> Новый набор
</button>
</div>
<div class="pr-progress" id="pr-progress"></div>`;
}
function wireControls() {
document.getElementById('tp-exclude').onchange = (e) => {
excludeSolved = e.target.checked;
};
document.getElementById('tp-count').onchange = (e) => {
count = Number(e.target.value) || 10;
persistCount(examKey, count);
};
document.getElementById('tp-restart').onclick = () => loadBatch();
}
function renderBatch() {
const empty = main.querySelector('.ep-empty');
const taskContainer = document.createElement('div');
taskContainer.className = 'pr-tasks';
if (empty) empty.replaceWith(taskContainer);
batch.tasks.forEach((task, i) => {
results.set(task.id, { isCorrect: null, attempts: 0 });
EP.TaskCard.render(taskContainer, task, {
mode: 'topic',
sessionId: batch.session_id,
numbering: i + 1,
onAttempt: ({ taskId, isCorrect, attempt }) => {
if (isCorrect == null) return;
const r = results.get(taskId);
if (!r) return;
if (r.isCorrect == null) r.isCorrect = isCorrect;
r.attempts = attempt || r.attempts + 1;
renderProgress();
maybeFinalize();
},
});
});
// Bottom finish row
const finish = document.createElement('div');
finish.className = 'pr-finish-row';
finish.innerHTML = `<button class="ep-btn pr-finish-btn">
<i data-lucide="flag"></i> Завершить и показать итог
</button>`;
main.appendChild(finish);
finish.querySelector('.pr-finish-btn').onclick = finalize;
renderProgress();
if (window.lucide) lucide.createIcons();
}
function renderProgress() {
const el = document.getElementById('pr-progress');
if (!el || !batch) return;
const total = batch.tasks.length;
const answered = Array.from(results.values()).filter(r => r.isCorrect != null).length;
const correct = Array.from(results.values()).filter(r => r.isCorrect === 1).length;
const pct = total ? Math.round((answered / total) * 100) : 0;
el.innerHTML = `
<div class="pr-progress-meta">
<span>Прогресс набора</span>
<span class="pr-progress-counts">${answered}/${total} · ${correct} верно</span>
</div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pct}%"></div></div>`;
}
function maybeFinalize() {
const answered = Array.from(results.values()).filter(r => r.isCorrect != null).length;
if (answered === batch.tasks.length && !finalized) finalize();
}
function finalize() {
if (finalized) return;
finalized = true;
const total = batch.tasks.length;
const correct = Array.from(results.values()).filter(r => r.isCorrect === 1).length;
const acc = total ? Math.round((correct / total) * 100) : 0;
const summary = document.createElement('div');
summary.className = 'pr-summary ep-card';
summary.innerHTML = `
<div class="pr-summary-grid">
<div class="pr-summary-stat">
<div class="ep-stat-label">Тема</div>
<div class="ep-stat-value" style="font-size:1.1rem">${escapeHtml(batch.topic.title)}</div>
</div>
<div class="pr-summary-stat">
<div class="ep-stat-label">Верно</div>
<div class="ep-stat-value ${acc >= 70 ? 'ep-good' : 'ep-warn'}">${correct}/${total}</div>
<div class="ep-stat-sub">${acc}% точности</div>
</div>
</div>
<div class="ep-cta-row">
<button class="ep-btn ep-btn-primary" id="pr-summary-restart">
<i data-lucide="rotate-cw"></i> Ещё ${count} задач
</button>
<a class="ep-btn" href="/exam-prep/${examKey}/topics">
<i data-lucide="tag"></i> К списку тем
</a>
</div>`;
main.appendChild(summary);
summary.querySelector('#pr-summary-restart').onclick = loadBatch;
if (window.lucide) lucide.createIcons();
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
/* ════════════════════════════════════════════════════════════════
Utils
════════════════════════════════════════════════════════════════ */
function readPersistedCount(examKey) {
try {
const n = Number(localStorage.getItem(`exam_prep_${examKey}_topic_count`));
return (n >= 5 && n <= 30) ? n : 10;
} catch { return 10; }
}
function persistCount(examKey, n) {
try { localStorage.setItem(`exam_prep_${examKey}_topic_count`, String(n)); } catch {}
}
function errorHtml(title, e) {
return `<div class="ep-empty">
<i data-lucide="alert-triangle"></i>
<h4>${escapeHtml(title)}</h4>
<p>${escapeHtml(e?.message || String(e))}</p>
</div>`;
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}