481 lines
19 KiB
JavaScript
481 lines
19 KiB
JavaScript
// =======================================================================
|
||
// CEFR Level Testing & Automated Gap Ingestion Module (Native ES Module)
|
||
// Oxford 3000 / 5000 Benchmark Testing Engine
|
||
// =======================================================================
|
||
|
||
import { api } from './api.js';
|
||
import { showToast, escapeHtml } from './ui.js';
|
||
|
||
let currentLevel = 'A1';
|
||
let currentMode = 'all';
|
||
let currentQuiz = [];
|
||
let currentQuestionIdx = 0;
|
||
let isAnswered = false;
|
||
|
||
const sessionStats = {
|
||
total: 0,
|
||
correct: 0,
|
||
mistakes: 0,
|
||
ingested: 0,
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Initializer
|
||
// -----------------------------------------------------------------------
|
||
export function initCefr() {
|
||
const backBtn = document.getElementById('cefr-back-btn');
|
||
const nextBtn = document.getElementById('cefr-next-btn');
|
||
const sumRepeatBtn = document.getElementById('cefr-sum-repeat-btn');
|
||
const sumLevelsBtn = document.getElementById('cefr-sum-levels-btn');
|
||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||
|
||
if (backBtn) {
|
||
backBtn.addEventListener('click', () => {
|
||
showCefrScreen('overview');
|
||
window.location.hash = '#cefr';
|
||
loadCefrOverview();
|
||
});
|
||
}
|
||
|
||
if (nextBtn) {
|
||
nextBtn.addEventListener('click', () => {
|
||
advanceQuestion();
|
||
});
|
||
}
|
||
|
||
if (sumRepeatBtn) {
|
||
sumRepeatBtn.addEventListener('click', () => {
|
||
startCefrQuiz(currentLevel, currentMode);
|
||
});
|
||
}
|
||
|
||
if (sumLevelsBtn) {
|
||
sumLevelsBtn.addEventListener('click', () => {
|
||
showCefrScreen('overview');
|
||
window.location.hash = '#cefr';
|
||
loadCefrOverview();
|
||
});
|
||
}
|
||
|
||
if (sumSrsBtn) {
|
||
sumSrsBtn.addEventListener('click', () => {
|
||
const levelTag = `level-${currentLevel.toLowerCase()}`;
|
||
window.location.hash = `#srs/${encodeURIComponent(levelTag)}`;
|
||
});
|
||
}
|
||
|
||
// Keyboard navigation (1-4 for options, Space/Enter for next)
|
||
window.addEventListener('keydown', (e) => {
|
||
const cefrView = document.getElementById('cefr-view');
|
||
const quizScreen = document.getElementById('cefr-quiz-screen');
|
||
if (!cefrView || cefrView.classList.contains('hidden')) return;
|
||
if (!quizScreen || quizScreen.classList.contains('hidden')) return;
|
||
|
||
// Ignore if user is inside an input/textarea
|
||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
|
||
|
||
if (!isAnswered) {
|
||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||
e.preventDefault();
|
||
const idx = parseInt(e.key, 10) - 1;
|
||
const btns = document.querySelectorAll('.cefr-opt-btn');
|
||
if (btns[idx]) btns[idx].click();
|
||
}
|
||
} else {
|
||
if (e.key === ' ' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
advanceQuestion();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Screen Switcher inside #cefr-view
|
||
// -----------------------------------------------------------------------
|
||
function showCefrScreen(screenName) {
|
||
const overview = document.getElementById('cefr-overview-screen');
|
||
const quiz = document.getElementById('cefr-quiz-screen');
|
||
const summary = document.getElementById('cefr-summary-screen');
|
||
|
||
if (overview) overview.classList.toggle('hidden', screenName !== 'overview');
|
||
if (quiz) quiz.classList.toggle('hidden', screenName !== 'quiz');
|
||
if (summary) summary.classList.toggle('hidden', screenName !== 'summary');
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Load & Render Overview
|
||
// -----------------------------------------------------------------------
|
||
export async function loadCefrOverview() {
|
||
showCefrScreen('overview');
|
||
const grid = document.getElementById('cefr-levels-grid');
|
||
if (!grid) return;
|
||
|
||
grid.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка уровней...</div>';
|
||
|
||
try {
|
||
const data = await api.cefr.getLevels();
|
||
const levels = data.levels || [];
|
||
renderLevelsGrid(levels);
|
||
} catch (err) {
|
||
grid.innerHTML = `<div class="empty-state"><p>Ошибка загрузки: ${escapeHtml(err.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderLevelsGrid(levels) {
|
||
const grid = document.getElementById('cefr-levels-grid');
|
||
if (!grid) return;
|
||
|
||
if (!levels.length) {
|
||
grid.innerHTML = '<div class="empty-state"><p>Нет доступных уровней</p></div>';
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = levels.map(lvl => {
|
||
const pct = lvl.progress_percent || 0;
|
||
const hasMistakes = lvl.in_learning_count > 0;
|
||
const colorClass = `level-badge-${lvl.level.toLowerCase()}`;
|
||
|
||
return `
|
||
<div class="cefr-level-card" data-level="${lvl.level}">
|
||
<div class="level-card-header">
|
||
<div class="level-pill ${colorClass}">
|
||
<span class="level-code">${escapeHtml(lvl.level)}</span>
|
||
</div>
|
||
<div class="level-header-text">
|
||
<h3 class="level-title">${escapeHtml(lvl.title)}</h3>
|
||
<span class="level-meta-sub">${lvl.total_questions} эталонных слов</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="level-card-progress">
|
||
<div class="progress-bar-wrap">
|
||
<div class="progress-bar-fill ${colorClass}" style="width: ${pct}%"></div>
|
||
</div>
|
||
<div class="progress-labels-row">
|
||
<span class="pct-text">${pct}% освоено</span>
|
||
<span class="counts-text">${lvl.mastered_count} / ${lvl.total_questions}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="level-card-metrics">
|
||
<div class="mini-metric">
|
||
<span class="m-val text-success">✓ ${lvl.mastered_count}</span>
|
||
<span class="m-lbl">Освоено</span>
|
||
</div>
|
||
<div class="mini-metric">
|
||
<span class="m-val text-warning">⚠️ ${lvl.in_learning_count}</span>
|
||
<span class="m-lbl">Ошибок</span>
|
||
</div>
|
||
<div class="mini-metric">
|
||
<span class="m-val text-primary">🎓 ${lvl.srs_cards_count}</span>
|
||
<span class="m-lbl">В словаре SRS</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="level-card-actions">
|
||
<button type="button" class="btn btn-primary btn-block cefr-start-btn" data-level="${lvl.level}" data-mode="all">
|
||
🎯 Начать тест (10)
|
||
</button>
|
||
${hasMistakes ? `
|
||
<button type="button" class="btn btn-outline btn-block cefr-mistakes-btn" data-level="${lvl.level}" data-mode="mistakes">
|
||
⚠️ Работа над ошибками (${lvl.in_learning_count})
|
||
</button>
|
||
` : ''}
|
||
<button type="button" class="btn btn-xs btn-ghost cefr-reset-btn" data-level="${lvl.level}" title="Сбросить прогресс уровня">
|
||
🔄 Сброс
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Attach button listeners
|
||
grid.querySelectorAll('.cefr-start-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const lvl = btn.dataset.level;
|
||
const mode = btn.dataset.mode || 'all';
|
||
window.location.hash = `#cefr/${encodeURIComponent(lvl)}`;
|
||
startCefrQuiz(lvl, mode);
|
||
});
|
||
});
|
||
|
||
grid.querySelectorAll('.cefr-mistakes-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const lvl = btn.dataset.level;
|
||
window.location.hash = `#cefr/${encodeURIComponent(lvl)}/mistakes`;
|
||
startCefrQuiz(lvl, 'mistakes');
|
||
});
|
||
});
|
||
|
||
grid.querySelectorAll('.cefr-reset-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const lvl = btn.dataset.level;
|
||
if (confirm(`Вы действительно хотите сбросить прогресс прохождения уровня ${lvl}?`)) {
|
||
try {
|
||
await api.cefr.resetLevel(lvl);
|
||
showToast(`Прогресс уровня ${lvl} сброшен`, 'info');
|
||
loadCefrOverview();
|
||
} catch (e) {
|
||
showToast('Ошибка сброса прогресса', 'error');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Start Quiz Session
|
||
// -----------------------------------------------------------------------
|
||
export async function startCefrQuiz(level = 'A1', mode = 'all') {
|
||
currentLevel = level.toUpperCase();
|
||
currentMode = mode;
|
||
currentQuestionIdx = 0;
|
||
isAnswered = false;
|
||
|
||
sessionStats.total = 0;
|
||
sessionStats.correct = 0;
|
||
sessionStats.mistakes = 0;
|
||
sessionStats.ingested = 0;
|
||
|
||
showCefrScreen('quiz');
|
||
|
||
const cardContainer = document.getElementById('cefr-cloze-card');
|
||
if (cardContainer) {
|
||
cardContainer.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка теста...</div>';
|
||
}
|
||
|
||
try {
|
||
const resp = await api.cefr.getQuiz(currentLevel, 10, currentMode);
|
||
currentQuiz = resp.quiz || [];
|
||
sessionStats.total = currentQuiz.length;
|
||
|
||
if (!currentQuiz.length) {
|
||
if (currentMode === 'mistakes') {
|
||
showToast(`На уровне ${currentLevel} нет неисправленных ошибок! 🏆`, 'success');
|
||
showCefrScreen('overview');
|
||
loadCefrOverview();
|
||
return;
|
||
}
|
||
showToast('Нет доступных вопросов для этого уровня', 'warning');
|
||
showCefrScreen('overview');
|
||
loadCefrOverview();
|
||
return;
|
||
}
|
||
|
||
renderCurrentQuestion();
|
||
} catch (err) {
|
||
showToast(`Ошибка загрузки вопросов: ${err.message}`, 'error');
|
||
showCefrScreen('overview');
|
||
loadCefrOverview();
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Render Active Question
|
||
// -----------------------------------------------------------------------
|
||
function renderCurrentQuestion() {
|
||
if (currentQuestionIdx >= currentQuiz.length) {
|
||
renderQuizSummary();
|
||
return;
|
||
}
|
||
|
||
isAnswered = false;
|
||
const q = currentQuiz[currentQuestionIdx];
|
||
|
||
// Header info
|
||
const levelBadge = document.getElementById('cefr-quiz-level-badge');
|
||
const counterBadge = document.getElementById('cefr-quiz-counter');
|
||
const progressBar = document.getElementById('cefr-quiz-progress-bar');
|
||
const nextBar = document.getElementById('cefr-next-bar');
|
||
|
||
if (levelBadge) {
|
||
levelBadge.textContent = `Уровень ${q.level}`;
|
||
levelBadge.className = `level-pill level-badge-${q.level.toLowerCase()}`;
|
||
}
|
||
if (counterBadge) {
|
||
counterBadge.textContent = `${currentQuestionIdx + 1} / ${currentQuiz.length}`;
|
||
}
|
||
if (progressBar) {
|
||
const pct = ((currentQuestionIdx) / currentQuiz.length) * 100;
|
||
progressBar.style.width = `${pct}%`;
|
||
}
|
||
if (nextBar) nextBar.classList.add('hidden');
|
||
|
||
const cardContainer = document.getElementById('cefr-cloze-card');
|
||
if (!cardContainer) return;
|
||
|
||
// Stylize the sentence blank: replace ___ with a visible blank node
|
||
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
||
|
||
cardContainer.innerHTML = `
|
||
<div class="cefr-cloze-header">
|
||
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
|
||
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
|
||
</div>
|
||
|
||
<div class="cefr-sentence-wrap">
|
||
<p class="cefr-sentence-text">${formattedSentence}</p>
|
||
</div>
|
||
|
||
<div class="cefr-clue-box">
|
||
<span class="clue-icon">💡</span>
|
||
<span class="clue-label">Значение:</span>
|
||
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
||
</div>
|
||
|
||
<div class="cefr-options-grid" id="cefr-options-grid">
|
||
${q.options.map((opt, idx) => `
|
||
<button type="button" class="cefr-opt-btn" data-option="${escapeHtml(opt)}">
|
||
<span class="opt-num">[${idx + 1}]</span>
|
||
<span class="opt-text">${escapeHtml(opt)}</span>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
||
`;
|
||
|
||
// Attach click listeners to option buttons
|
||
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const opt = btn.dataset.option;
|
||
handleOptionTap(q.id, opt, btn);
|
||
});
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Handle Option Selection (1-tap Cloze Tap)
|
||
// -----------------------------------------------------------------------
|
||
async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||
if (isAnswered) return;
|
||
isAnswered = true;
|
||
|
||
// Disable all options
|
||
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||
b.disabled = true;
|
||
});
|
||
|
||
const feedbackBox = document.getElementById('cefr-feedback-box');
|
||
const nextBar = document.getElementById('cefr-next-bar');
|
||
const blankTarget = document.getElementById('cefr-blank-target');
|
||
|
||
try {
|
||
const res = await api.cefr.submitAnswer(questionId, selectedOption);
|
||
|
||
if (res.correct) {
|
||
sessionStats.correct++;
|
||
clickedBtn.classList.add('opt-correct');
|
||
if (blankTarget) {
|
||
blankTarget.textContent = res.correct_option;
|
||
blankTarget.classList.add('blank-correct');
|
||
}
|
||
|
||
if (feedbackBox) {
|
||
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
||
feedbackBox.innerHTML = `
|
||
<div class="fb-title">✓ Верно! Отличный выбор.</div>
|
||
<div class="fb-meta">
|
||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||
</div>
|
||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||
`;
|
||
feedbackBox.classList.remove('hidden');
|
||
}
|
||
} else {
|
||
sessionStats.mistakes++;
|
||
if (res.card_created || res.card_updated) {
|
||
sessionStats.ingested++;
|
||
}
|
||
|
||
clickedBtn.classList.add('opt-wrong');
|
||
|
||
// Highlight the true correct option
|
||
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||
if (b.dataset.option.toLowerCase() === res.correct_option.toLowerCase()) {
|
||
b.classList.add('opt-correct-revealed');
|
||
}
|
||
});
|
||
|
||
if (blankTarget) {
|
||
blankTarget.textContent = res.correct_option;
|
||
blankTarget.classList.add('blank-wrong-revealed');
|
||
}
|
||
|
||
if (feedbackBox) {
|
||
feedbackBox.className = 'cefr-feedback-box feedback-wrong';
|
||
feedbackBox.innerHTML = `
|
||
<div class="fb-title">✗ Неверно. Правильное слово: <strong>${escapeHtml(res.correct_option)}</strong></div>
|
||
<div class="fb-meta">
|
||
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
|
||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||
</div>
|
||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||
<div class="cefr-gap-ingested-badge">
|
||
<span class="badge-icon">📥</span>
|
||
<div class="badge-text">
|
||
<strong>Авто-захват в словарь:</strong> слово добавлено со статусом «В процессе» для закрепления в утреннем SRS!
|
||
</div>
|
||
</div>
|
||
`;
|
||
feedbackBox.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
if (nextBar) nextBar.classList.remove('hidden');
|
||
|
||
} catch (err) {
|
||
showToast(`Ошибка проверки ответа: ${err.message}`, 'error');
|
||
if (nextBar) nextBar.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Advance to Next Question
|
||
// -----------------------------------------------------------------------
|
||
function advanceQuestion() {
|
||
currentQuestionIdx++;
|
||
renderCurrentQuestion();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Render Quiz Summary
|
||
// -----------------------------------------------------------------------
|
||
function renderQuizSummary() {
|
||
showCefrScreen('summary');
|
||
|
||
const total = sessionStats.total || currentQuiz.length || 1;
|
||
const correct = sessionStats.correct;
|
||
const mistakes = sessionStats.mistakes;
|
||
const ingested = sessionStats.ingested;
|
||
const pct = Math.round((correct / total) * 100);
|
||
|
||
const sumScore = document.getElementById('cefr-sum-score');
|
||
const sumLead = document.getElementById('cefr-sum-lead');
|
||
const sumCorrect = document.getElementById('cefr-sum-correct');
|
||
const sumMistakes = document.getElementById('cefr-sum-mistakes');
|
||
const sumIngested = document.getElementById('cefr-sum-ingested');
|
||
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||
|
||
if (sumScore) sumScore.textContent = `${pct}%`;
|
||
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
|
||
if (sumMistakes) sumMistakes.textContent = mistakes;
|
||
if (sumIngested) sumIngested.textContent = ingested;
|
||
|
||
if (sumLead) {
|
||
if (pct >= 90) {
|
||
sumLead.textContent = `Великолепно! Уровень ${currentLevel} освоен на высочайшем уровне! 🌟`;
|
||
} else if (pct >= 70) {
|
||
sumLead.textContent = `Хороший результат! Большинство ключевых слов уровня ${currentLevel} вам знакомы. 👍`;
|
||
} else {
|
||
sumLead.textContent = `Тренировка завершена. Все ошибки бережно сохранены в ваш SRS для закрепления. 💪`;
|
||
}
|
||
}
|
||
|
||
if (sumSrsBtn) {
|
||
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
|
||
}
|
||
}
|