2143 lines
93 KiB
JavaScript
2143 lines
93 KiB
JavaScript
// =======================================================================
|
|
// Notes Application - Comprehensive Lesson Builder & Runner Module (Beta)
|
|
// Enables custom daily workouts: Words -> Grammar -> Listening -> Reading -> AI
|
|
// =======================================================================
|
|
|
|
import { api } from './api.js';
|
|
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
|
|
|
|
let presets = [];
|
|
let currentPreset = null;
|
|
let customSteps = [];
|
|
let grammarRecs = null;
|
|
let articlesList = [];
|
|
let userBooks = [];
|
|
|
|
// Active Session State
|
|
let session = {
|
|
active: false,
|
|
presetName: '',
|
|
steps: [],
|
|
currentStepIdx: 0,
|
|
startTime: 0,
|
|
timerInterval: null,
|
|
elapsedSeconds: 0,
|
|
stepResults: [],
|
|
errors: [],
|
|
currentStepState: null,
|
|
};
|
|
|
|
export async function initLessonModule() {
|
|
setupEventListeners();
|
|
await loadInitialData();
|
|
}
|
|
|
|
async function loadInitialData() {
|
|
try {
|
|
const [presetsRes, recsRes, articlesRes] = await Promise.all([
|
|
api.lessons.getPresets().catch(() => ({ presets: [] })),
|
|
api.lessons.getGrammarRecommendations().catch(() => null),
|
|
api.lessons.getArticles().catch(() => ({ articles: [] })),
|
|
]);
|
|
presets = presetsRes.presets || [];
|
|
grammarRecs = recsRes;
|
|
articlesList = articlesRes.articles || [];
|
|
|
|
// Select first preset by default
|
|
if (presets.length > 0 && !currentPreset) {
|
|
selectPreset(presets[0].id);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading lesson initial data:', e);
|
|
}
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
// Top-level tabs / navigation
|
|
const startBtn = document.getElementById('lesson-start-btn');
|
|
if (startBtn) {
|
|
startBtn.addEventListener('click', () => startLessonSession());
|
|
}
|
|
|
|
const savePresetBtn = document.getElementById('lesson-save-preset-btn');
|
|
if (savePresetBtn) {
|
|
savePresetBtn.addEventListener('click', () => promptSavePreset());
|
|
}
|
|
|
|
// Runner controls
|
|
const exitBtn = document.getElementById('lesson-runner-exit-btn');
|
|
if (exitBtn) {
|
|
exitBtn.addEventListener('click', () => confirmExitSession());
|
|
}
|
|
|
|
const nextStepBtn = document.getElementById('lesson-next-step-btn');
|
|
if (nextStepBtn) {
|
|
nextStepBtn.addEventListener('click', () => advanceStep());
|
|
}
|
|
|
|
// History modal
|
|
const historyBtn = document.getElementById('lesson-history-btn');
|
|
if (historyBtn) {
|
|
historyBtn.addEventListener('click', () => openHistoryModal());
|
|
}
|
|
|
|
// Runner keyboard shortcuts listener
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!session.active) return;
|
|
if (typeof session.currentStepKeyHandler === 'function') {
|
|
session.currentStepKeyHandler(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurator UI & Presets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function openLessonConfigurator(presetId = null) {
|
|
ui.switchView('lesson-view');
|
|
await loadInitialData();
|
|
|
|
if (presetId) {
|
|
selectPreset(presetId);
|
|
} else if (currentPreset) {
|
|
renderConfigurator();
|
|
}
|
|
}
|
|
|
|
export function selectPreset(presetId) {
|
|
const found = presets.find(p => p.id === presetId);
|
|
if (!found) return;
|
|
currentPreset = found;
|
|
// Deep clone steps
|
|
customSteps = JSON.parse(JSON.stringify(found.steps || []));
|
|
renderConfigurator();
|
|
}
|
|
|
|
function renderConfigurator() {
|
|
// 1. Render Preset Chips
|
|
const chipsContainer = document.getElementById('lesson-presets-bar');
|
|
if (chipsContainer) {
|
|
chipsContainer.innerHTML = presets.map(p => `
|
|
<button class="lesson-preset-chip ${currentPreset && currentPreset.id === p.id ? 'active' : ''}"
|
|
data-id="${p.id}">
|
|
<span class="preset-icon">${p.icon || '🚀'}</span>
|
|
<span class="preset-name">${escapeHtml(p.name)}</span>
|
|
${p.is_custom ? `<span class="preset-del-btn" data-del="${p.custom_id}" title="Удалить шаблон">✕</span>` : ''}
|
|
</button>
|
|
`).join('');
|
|
|
|
chipsContainer.querySelectorAll('.lesson-preset-chip').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('preset-del-btn')) {
|
|
e.stopPropagation();
|
|
deleteCustomPreset(parseInt(e.target.dataset.del, 10));
|
|
return;
|
|
}
|
|
selectPreset(btn.dataset.id);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 2. Preset Description & Duration
|
|
const descEl = document.getElementById('lesson-preset-desc');
|
|
if (descEl && currentPreset) {
|
|
descEl.textContent = currentPreset.description || '';
|
|
}
|
|
|
|
const timeEl = document.getElementById('lesson-preset-duration');
|
|
if (timeEl && currentPreset) {
|
|
timeEl.textContent = `~${currentPreset.estimated_min || 15} мин`;
|
|
}
|
|
|
|
// 3. Render Configurable Step Cards
|
|
renderStepCards();
|
|
}
|
|
|
|
function renderStepCards() {
|
|
const list = document.getElementById('lesson-steps-list');
|
|
if (!list) return;
|
|
|
|
if (!customSteps || customSteps.length === 0) {
|
|
list.innerHTML = `<div class="empty-state">Нет шагов в уроке. Добавьте блоки.</div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = customSteps.map((step, idx) => {
|
|
const isFirst = idx === 0;
|
|
const isLast = idx === customSteps.length - 1;
|
|
const isAi = step.type === 'ai_debrief';
|
|
|
|
return `
|
|
<div class="lesson-step-card ${step.enabled ? '' : 'step-disabled'}" data-idx="${idx}">
|
|
<div class="step-card-header">
|
|
<div class="step-card-title-wrap">
|
|
<span class="step-num">${idx + 1}</span>
|
|
<span class="step-icon">${getStepIcon(step.type)}</span>
|
|
<strong class="step-title">${escapeHtml(step.title || getStepDefaultTitle(step.type))}</strong>
|
|
</div>
|
|
|
|
<div class="step-card-actions">
|
|
${!isAi ? `
|
|
<button class="icon-btn btn-sm move-step-up" data-idx="${idx}" ${isFirst ? 'disabled' : ''} title="Поднять выше">▲</button>
|
|
<button class="icon-btn btn-sm move-step-down" data-idx="${idx}" ${isLast || customSteps[idx+1]?.type === 'ai_debrief' ? 'disabled' : ''} title="Опустить ниже">▼</button>
|
|
<label class="switch-toggle" title="Включить / выключить шаг">
|
|
<input type="checkbox" class="step-toggle-checkbox" data-idx="${idx}" ${step.enabled ? 'checked' : ''}>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
` : '<span class="badge badge-ai">Финальный ИИ-анализ</span>'}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="step-card-body ${step.enabled ? '' : 'hidden'}">
|
|
${renderStepControls(step, idx)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Attach step card events
|
|
list.querySelectorAll('.move-step-up').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const idx = parseInt(btn.dataset.idx, 10);
|
|
if (idx > 0) {
|
|
const tmp = customSteps[idx];
|
|
customSteps[idx] = customSteps[idx - 1];
|
|
customSteps[idx - 1] = tmp;
|
|
renderStepCards();
|
|
}
|
|
});
|
|
});
|
|
|
|
list.querySelectorAll('.move-step-down').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const idx = parseInt(btn.dataset.idx, 10);
|
|
if (idx < customSteps.length - 1 && customSteps[idx + 1].type !== 'ai_debrief') {
|
|
const tmp = customSteps[idx];
|
|
customSteps[idx] = customSteps[idx + 1];
|
|
customSteps[idx + 1] = tmp;
|
|
renderStepCards();
|
|
}
|
|
});
|
|
});
|
|
|
|
list.querySelectorAll('.step-toggle-checkbox').forEach(cb => {
|
|
cb.addEventListener('change', () => {
|
|
const idx = parseInt(cb.dataset.idx, 10);
|
|
customSteps[idx].enabled = cb.checked;
|
|
renderStepCards();
|
|
});
|
|
});
|
|
|
|
attachStepControlsEvents(list);
|
|
}
|
|
|
|
function getStepIcon(type) {
|
|
switch (type) {
|
|
case 'vocab': return '📇';
|
|
case 'grammar_review': return '🎯';
|
|
case 'grammar_new': return '📘';
|
|
case 'listening': return '🎧';
|
|
case 'reading': return '📖';
|
|
case 'ai_debrief': return '🤖';
|
|
default: return '📌';
|
|
}
|
|
}
|
|
|
|
function getStepDefaultTitle(type) {
|
|
switch (type) {
|
|
case 'vocab': return 'Вспоминаем слова (SRS)';
|
|
case 'grammar_review': return 'Закрепление грамматики';
|
|
case 'grammar_new': return 'Новая тема Мерфи';
|
|
case 'listening': return 'Аудирование на слух';
|
|
case 'reading': return 'Чтение и словарная работа';
|
|
case 'ai_debrief': return 'ИИ-Итог и рекомендации';
|
|
default: return 'Этап урока';
|
|
}
|
|
}
|
|
|
|
function renderStepControls(step, idx) {
|
|
if (step.type === 'vocab') {
|
|
const currentMode = step.mode || 'flashcards';
|
|
return `
|
|
<div class="step-param-row">
|
|
<div class="param-group">
|
|
<label>Количество слов:</label>
|
|
<div class="segmented-control" data-param="count" data-idx="${idx}">
|
|
${[5, 10, 15, 20].map(c => `
|
|
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="param-group flex-2">
|
|
<label>Режим тренировки слов:</label>
|
|
<div class="segmented-control mode-segmented-control" data-param="mode" data-idx="${idx}">
|
|
<button type="button" class="seg-btn ${currentMode === 'flashcards' ? 'active' : ''}" data-val="flashcards">📇 Карточки</button>
|
|
<button type="button" class="seg-btn ${currentMode === 'quiz' ? 'active' : ''}" data-val="quiz">🎯 4 Варианта</button>
|
|
<button type="button" class="seg-btn ${currentMode === 'typing' ? 'active' : ''}" data-val="typing">✍️ Набор</button>
|
|
<button type="button" class="seg-btn ${currentMode === 'dictation' ? 'active' : ''}" data-val="dictation">🎧 Диктант</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (step.type === 'grammar_review') {
|
|
const weakest = grammarRecs?.weakest_unit;
|
|
const prev = grammarRecs?.previous_unit;
|
|
const allUnits = grammarRecs?.all_units || [];
|
|
|
|
return `
|
|
<div class="step-param-row">
|
|
<div class="param-group flex-2">
|
|
<label>Выбор темы для повторения:</label>
|
|
<select class="input-styled step-select-param" data-param="topic_mode" data-idx="${idx}">
|
|
<option value="weakest" ${step.topic_mode === 'weakest' ? 'selected' : ''}>
|
|
🎯 Самая слабая тема ${weakest ? `(Юнит ${weakest.unit_num}: ${weakest.title})` : ''}
|
|
</option>
|
|
<option value="previous" ${step.topic_mode === 'previous' ? 'selected' : ''}>
|
|
⏳ Предыдущая тема ${prev ? `(Юнит ${prev.unit_num}: ${prev.title})` : ''}
|
|
</option>
|
|
<optgroup label="Выбрать конкретный юнит вручную:">
|
|
${allUnits.map(u => `
|
|
<option value="unit_${u.unit_num}" ${step.topic_mode === `unit_${u.unit_num}` ? 'selected' : ''}>
|
|
Юнит ${u.unit_num}: ${escapeHtml(u.title)} (${u.level})
|
|
</option>
|
|
`).join('')}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
<div class="param-group">
|
|
<label>Вопросов:</label>
|
|
<div class="segmented-control" data-param="count" data-idx="${idx}">
|
|
${[3, 5, 10].map(c => `
|
|
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (step.type === 'grammar_new') {
|
|
const next = grammarRecs?.next_unit;
|
|
const allUnits = grammarRecs?.all_units || [];
|
|
|
|
return `
|
|
<div class="step-param-row">
|
|
<div class="param-group flex-2">
|
|
<label>Тема для изучения:</label>
|
|
<select class="input-styled step-select-param" data-param="unit_mode" data-idx="${idx}">
|
|
<option value="auto_next" ${step.unit_mode === 'auto_next' ? 'selected' : ''}>
|
|
✨ Следующая по программе ${next ? `(Юнит ${next.unit_num}: ${next.title})` : ''}
|
|
</option>
|
|
<optgroup label="Выбрать тему вручную:">
|
|
${allUnits.map(u => `
|
|
<option value="unit_${u.unit_num}" ${step.unit_mode === `unit_${u.unit_num}` ? 'selected' : ''}>
|
|
Юнит ${u.unit_num}: ${escapeHtml(u.title)}
|
|
</option>
|
|
`).join('')}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
<div class="param-group">
|
|
<label>Упражнений:</label>
|
|
<div class="segmented-control" data-param="exercises_count" data-idx="${idx}">
|
|
${[3, 5, 8].map(c => `
|
|
<button type="button" class="seg-btn ${step.exercises_count === c ? 'active' : ''}" data-val="${c}">${c}</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (step.type === 'listening') {
|
|
return `
|
|
<div class="step-param-row">
|
|
<div class="param-group">
|
|
<label>Количество ситуаций:</label>
|
|
<div class="segmented-control" data-param="count" data-idx="${idx}">
|
|
${[1, 2, 3].map(c => `
|
|
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="param-group">
|
|
<label>Тематика аудио:</label>
|
|
<select class="input-styled step-select-param" data-param="topic" data-idx="${idx}">
|
|
<option value="all" ${step.topic === 'all' ? 'selected' : ''}>Все темы (микс)</option>
|
|
<option value="tech" ${step.topic === 'tech' ? 'selected' : ''}>💻 IT & Технологии</option>
|
|
<option value="science" ${step.topic === 'science' ? 'selected' : ''}>🔬 Наука & Космос</option>
|
|
<option value="travel" ${step.topic === 'travel' ? 'selected' : ''}>✈️ Путешествия & Жизнь</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (step.type === 'reading') {
|
|
return `
|
|
<div class="step-param-row">
|
|
<div class="param-group flex-2">
|
|
<label>Источник для чтения:</label>
|
|
<select class="input-styled step-select-param" data-param="source" data-idx="${idx}">
|
|
<option value="curated_article" ${step.source === 'curated_article' ? 'selected' : ''}>
|
|
📰 Познавательная микро-статья / Научный факт (из оффлайн-базы)
|
|
</option>
|
|
<option value="active_book" ${step.source === 'active_book' ? 'selected' : ''}>
|
|
📖 Книга с полки (продолжить с текущей страницы)
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="param-group flex-2">
|
|
<label>Выбор статьи:</label>
|
|
<select class="input-styled step-select-param" data-param="article_id" data-idx="${idx}">
|
|
<option value="random" ${step.article_id === 'random' ? 'selected' : ''}>🎲 Случайная интересная статья</option>
|
|
${articlesList.map(a => `
|
|
<option value="${a.id}" ${step.article_id === a.id ? 'selected' : ''}>
|
|
[${a.topic}] ${escapeHtml(a.title)}
|
|
</option>
|
|
`).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (step.type === 'ai_debrief') {
|
|
return `
|
|
<div class="ai-debrief-info-box">
|
|
<span class="ai-box-icon">🤖</span>
|
|
<div class="ai-box-text">
|
|
<strong>Педагогический анализ сессии:</strong> нейросеть сведет все результаты воедино,
|
|
сопоставит ошибки с историей прошлых уроков, объяснит разницу по формулам Мерфи
|
|
и сформирует план на завтра.
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function attachStepControlsEvents(container) {
|
|
container.querySelectorAll('.segmented-control').forEach(ctrl => {
|
|
ctrl.querySelectorAll('.seg-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const idx = parseInt(ctrl.dataset.idx, 10);
|
|
const param = ctrl.dataset.param;
|
|
const rawVal = btn.dataset.val;
|
|
const val = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
|
|
if (customSteps[idx]) {
|
|
customSteps[idx][param] = val;
|
|
}
|
|
ctrl.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
updateEstimatedDuration();
|
|
});
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.step-select-param').forEach(sel => {
|
|
sel.addEventListener('change', () => {
|
|
const idx = parseInt(sel.dataset.idx, 10);
|
|
const param = sel.dataset.param;
|
|
if (customSteps[idx]) {
|
|
customSteps[idx][param] = sel.value;
|
|
}
|
|
updateEstimatedDuration();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateEstimatedDuration() {
|
|
const timeEl = document.getElementById('lesson-preset-duration');
|
|
if (timeEl) {
|
|
timeEl.textContent = `~${calculateEstimatedTime(customSteps)} мин`;
|
|
}
|
|
}
|
|
|
|
function syncDomToCustomSteps() {
|
|
const list = document.getElementById('lesson-steps-list');
|
|
if (!list) return;
|
|
list.querySelectorAll('.lesson-step-card').forEach(cardEl => {
|
|
const idx = parseInt(cardEl.dataset.idx, 10);
|
|
if (isNaN(idx) || !customSteps[idx]) return;
|
|
|
|
const cb = cardEl.querySelector('.step-toggle-checkbox');
|
|
if (cb) customSteps[idx].enabled = cb.checked;
|
|
|
|
cardEl.querySelectorAll('.segmented-control').forEach(ctrl => {
|
|
const param = ctrl.dataset.param;
|
|
const activeBtn = ctrl.querySelector('.seg-btn.active');
|
|
if (param && activeBtn) {
|
|
const rawVal = activeBtn.dataset.val;
|
|
customSteps[idx][param] = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
|
|
}
|
|
});
|
|
|
|
cardEl.querySelectorAll('.step-select-param').forEach(sel => {
|
|
const param = sel.dataset.param;
|
|
if (param && sel.value !== undefined) {
|
|
customSteps[idx][param] = sel.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function promptSavePreset() {
|
|
const name = prompt('Введите название вашего нового шаблона урока:', 'Мой тренировочный план');
|
|
if (!name || !name.strip?.() && !name.trim()) return;
|
|
|
|
try {
|
|
const res = await api.lessons.savePreset({
|
|
name: name.trim(),
|
|
steps: customSteps,
|
|
icon: '⚡',
|
|
description: 'Пользовательский шаблон',
|
|
estimated_min: calculateEstimatedTime(customSteps)
|
|
});
|
|
ui.showToast('✅ Шаблон урока успешно сохранён!', 'success');
|
|
await loadInitialData();
|
|
if (res.preset) {
|
|
selectPreset(res.preset.id);
|
|
}
|
|
} catch (e) {
|
|
ui.showToast(`Ошибка сохранения: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteCustomPreset(customId) {
|
|
if (!confirm('Вы действительно хотите удалить этот пользовательский шаблон?')) return;
|
|
try {
|
|
await api.lessons.deletePreset(customId);
|
|
ui.showToast('Шаблон удален', 'info');
|
|
await loadInitialData();
|
|
selectPreset(presets[0]?.id);
|
|
} catch (e) {
|
|
ui.showToast(`Ошибка удаления: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function calculateEstimatedTime(steps) {
|
|
let minutes = 0;
|
|
for (const s of steps) {
|
|
if (!s.enabled) continue;
|
|
if (s.type === 'vocab') minutes += Math.ceil((s.count || 10) * 0.4);
|
|
else if (s.type === 'grammar_review') minutes += Math.ceil((s.count || 5) * 0.6);
|
|
else if (s.type === 'grammar_new') minutes += 6;
|
|
else if (s.type === 'listening') minutes += (s.count || 1) * 2;
|
|
else if (s.type === 'reading') minutes += 4;
|
|
else if (s.type === 'ai_debrief') minutes += 1;
|
|
}
|
|
return Math.max(5, minutes);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lesson Session Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function startLessonSession(preset = null) {
|
|
if (!preset) {
|
|
syncDomToCustomSteps();
|
|
}
|
|
const stepsToRun = (preset ? preset.steps : customSteps).filter(s => s.enabled);
|
|
if (!stepsToRun || stepsToRun.length === 0) {
|
|
ui.showToast('В уроке нет активных блоков. Включите хотя бы один блок.', 'warning');
|
|
return;
|
|
}
|
|
|
|
session = {
|
|
active: true,
|
|
presetName: currentPreset?.name || 'Персональный урок',
|
|
steps: stepsToRun,
|
|
currentStepIdx: 0,
|
|
startTime: Date.now(),
|
|
timerInterval: null,
|
|
elapsedSeconds: 0,
|
|
stepResults: [],
|
|
errors: [],
|
|
currentStepState: null,
|
|
};
|
|
|
|
// Show Runner View
|
|
ui.switchView('lesson-runner-view');
|
|
|
|
// Start timer
|
|
clearInterval(session.timerInterval);
|
|
session.timerInterval = setInterval(() => {
|
|
session.elapsedSeconds++;
|
|
updateRunnerTimer();
|
|
}, 1000);
|
|
|
|
renderRunnerTopBar();
|
|
await launchStep(0);
|
|
}
|
|
|
|
function updateRunnerTimer() {
|
|
const el = document.getElementById('lesson-runner-timer');
|
|
if (!el) return;
|
|
const mins = Math.floor(session.elapsedSeconds / 60);
|
|
const secs = session.elapsedSeconds % 60;
|
|
el.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
}
|
|
|
|
function renderRunnerTopBar() {
|
|
const bar = document.getElementById('lesson-runner-breadcrumbs');
|
|
if (!bar) return;
|
|
|
|
bar.innerHTML = session.steps.map((st, idx) => {
|
|
let statusClass = 'step-pending';
|
|
if (idx < session.currentStepIdx) statusClass = 'step-done';
|
|
else if (idx === session.currentStepIdx) statusClass = 'step-active';
|
|
|
|
return `
|
|
<div class="runner-crumb ${statusClass}" title="${escapeHtml(st.title || st.type)}">
|
|
<span class="crumb-num">${idx < session.currentStepIdx ? '✓' : idx + 1}</span>
|
|
<span class="crumb-label">${getStepIcon(st.type)} ${escapeHtml(st.title || st.type)}</span>
|
|
</div>
|
|
`;
|
|
}).join('<div class="crumb-separator">➔</div>');
|
|
|
|
// Progress bar fill
|
|
const progressFill = document.getElementById('lesson-runner-progress-fill');
|
|
if (progressFill) {
|
|
const pct = Math.round((session.currentStepIdx / Math.max(1, session.steps.length)) * 100);
|
|
progressFill.style.width = `${pct}%`;
|
|
}
|
|
}
|
|
|
|
async function launchStep(idx) {
|
|
session.currentStepIdx = idx;
|
|
renderRunnerTopBar();
|
|
|
|
const step = session.steps[idx];
|
|
const container = document.getElementById('lesson-step-content');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = `
|
|
<div class="loading-state">
|
|
<div class="spinner"></div>
|
|
<p>Загрузка этапа: ${escapeHtml(step.title || step.type)}...</p>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
if (step.type === 'vocab') {
|
|
await runVocabStep(step, container);
|
|
} else if (step.type === 'grammar_review') {
|
|
await runGrammarReviewStep(step, container);
|
|
} else if (step.type === 'grammar_new') {
|
|
await runGrammarNewStep(step, container);
|
|
} else if (step.type === 'listening') {
|
|
await runListeningStep(step, container);
|
|
} else if (step.type === 'reading') {
|
|
await runReadingStep(step, container);
|
|
} else if (step.type === 'ai_debrief') {
|
|
await runAiDebriefStep(step, container);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error running step:', err);
|
|
container.innerHTML = `
|
|
<div class="error-state">
|
|
<p>Ошибка загрузки этапа: ${escapeHtml(err.message)}</p>
|
|
<button class="btn btn-primary" onclick="window.advanceLessonStep()">Пропустить этот этап</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
export function advanceStep() {
|
|
if (session.currentStepIdx + 1 < session.steps.length) {
|
|
launchStep(session.currentStepIdx + 1);
|
|
} else {
|
|
finishSession();
|
|
}
|
|
}
|
|
window.advanceLessonStep = advanceStep;
|
|
|
|
function confirmExitSession() {
|
|
if (confirm('Прервать текущий урок? Набранный прогресс по пройденным этапам сохранится.')) {
|
|
clearInterval(session.timerInterval);
|
|
session.active = false;
|
|
ui.switchView('lesson-view');
|
|
renderConfigurator();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 1: Vocab Runner (SRS)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runVocabStep(step, container) {
|
|
const limit = step.count || 10;
|
|
const mode = step.mode || 'flashcards';
|
|
|
|
// Fetch queue
|
|
const res = await api.srs.getQueue({ limit: limit });
|
|
let cards = res.cards || (Array.isArray(res) ? res : []);
|
|
|
|
if (cards.length === 0) {
|
|
// Fallback: fetch random cards from library
|
|
const allRes = await api.cards.getAll({ limit: limit });
|
|
cards = Array.isArray(allRes) ? allRes : (allRes.cards || []);
|
|
}
|
|
|
|
// Distractor candidate pool for quiz mode
|
|
let distractorPool = [];
|
|
if (mode === 'quiz') {
|
|
try {
|
|
const extraRes = await api.cards.getAll({ limit: 80 });
|
|
const extraCards = Array.isArray(extraRes) ? extraRes : (extraRes.cards || []);
|
|
distractorPool = extraCards.map(c => c.translation).filter(Boolean);
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (cards.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center">
|
|
<h3>📇 Вспоминаем слова</h3>
|
|
<p>В вашем словаре пока нет слов для повторения. Добавьте первые слова через поиск или читалку!</p>
|
|
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">Продолжить дальше ➔</button>
|
|
</div>
|
|
`;
|
|
session.stepResults.push({ type: 'vocab', title: step.title, score: 100, correct: 0, total: 0 });
|
|
return;
|
|
}
|
|
|
|
let cardIdx = 0;
|
|
let correctCount = 0;
|
|
const totalCards = cards.length;
|
|
let stepActive = true;
|
|
|
|
function renderCurrentCard() {
|
|
if (cardIdx >= totalCards) {
|
|
stepActive = false;
|
|
session.currentStepKeyHandler = null;
|
|
const score = Math.round((correctCount / totalCards) * 100);
|
|
session.stepResults.push({
|
|
type: 'vocab',
|
|
title: step.title,
|
|
score: score,
|
|
correct: correctCount,
|
|
total: totalCards,
|
|
mode: mode
|
|
});
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center step-completed-box">
|
|
<div class="celebrate-badge">🎉</div>
|
|
<h3>Блок слов завершён!</h3>
|
|
<p class="score-highlight">Результат: <strong>${correctCount} из ${totalCards}</strong> (${score}%)</p>
|
|
<div class="step-summary-badges mt-2">
|
|
<span class="badge badge-success">✓ Верно: ${correctCount}</span>
|
|
${totalCards - correctCount > 0 ? `<span class="badge badge-danger">✗ Ошибок: ${totalCards - correctCount}</span>` : ''}
|
|
</div>
|
|
<button class="btn btn-primary btn-lg mt-4" id="vocab-finish-btn">
|
|
Перейти к следующему блоку ➔
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('vocab-finish-btn')?.addEventListener('click', () => {
|
|
advanceStep();
|
|
});
|
|
return;
|
|
}
|
|
|
|
const card = cards[cardIdx];
|
|
|
|
if (mode === 'quiz') {
|
|
renderQuizCard(card);
|
|
} else if (mode === 'typing') {
|
|
renderTypingCard(card);
|
|
} else if (mode === 'dictation') {
|
|
renderDictationCard(card);
|
|
} else {
|
|
renderFlashcardCard(card);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode A: 4 Options Quiz
|
|
// -----------------------------------------------------------------------
|
|
function renderQuizCard(card) {
|
|
const correctText = cleanPromptTranslation(card.translation);
|
|
const candidates = [
|
|
...cards.filter(c => c.id !== card.id && c.translation).map(c => cleanPromptTranslation(c.translation)),
|
|
...distractorPool.filter(t => t && cleanPromptTranslation(t) !== correctText).map(t => cleanPromptTranslation(t))
|
|
];
|
|
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
|
shuffleArray(uniqueCandidates);
|
|
let options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
|
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
|
while (options.length < 4) {
|
|
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
|
options.push(f);
|
|
}
|
|
options = shuffleArray(options);
|
|
|
|
let answered = false;
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box lesson-quiz-card">
|
|
<div class="step-mini-header">
|
|
<div class="step-tags-wrap">
|
|
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
|
<span class="badge badge-outline">🎯 4 Варианта</span>
|
|
</div>
|
|
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
|
</div>
|
|
|
|
<div class="step-term-display mt-3">
|
|
<h2 class="display-term">${escapeHtml(card.term)}</h2>
|
|
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
|
|
</div>
|
|
|
|
${card.context_sentence ? `
|
|
<div class="step-context-box mt-3">
|
|
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="lesson-quiz-options-grid mt-4">
|
|
${options.map((opt, oIdx) => `
|
|
<button type="button" class="lesson-quiz-opt-btn" data-val="${escapeHtml(opt)}" data-num="${oIdx + 1}">
|
|
<span class="quiz-opt-num">${oIdx + 1}</span>
|
|
<span class="quiz-opt-text">${escapeHtml(opt)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div id="lesson-quiz-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
|
|
|
<div id="lesson-quiz-next-bar" class="hidden mt-3">
|
|
<button id="lesson-quiz-next-btn" class="btn btn-primary btn-lg w-100">
|
|
Продолжить ➔ (Enter или Пробел)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const playCardAudio = () => {
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
else playTextSpeech(card.term, 'en');
|
|
};
|
|
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
|
|
|
const optBtns = container.querySelectorAll('.lesson-quiz-opt-btn');
|
|
const handleOptionClick = (btn) => {
|
|
if (answered) return;
|
|
answered = true;
|
|
|
|
const selectedText = btn.dataset.val;
|
|
const isCorrect = selectedText === correctText;
|
|
|
|
optBtns.forEach(b => {
|
|
b.disabled = true;
|
|
if (b.dataset.val === correctText) {
|
|
b.classList.add('opt-correct');
|
|
} else if (b === btn && !isCorrect) {
|
|
b.classList.add('opt-wrong');
|
|
}
|
|
});
|
|
|
|
playCardAudio();
|
|
|
|
const fb = document.getElementById('lesson-quiz-feedback');
|
|
const nextBar = document.getElementById('lesson-quiz-next-bar');
|
|
const nextBtn = document.getElementById('lesson-quiz-next-btn');
|
|
|
|
if (isCorrect) {
|
|
correctCount++;
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-correct';
|
|
fb.innerHTML = '🎉 <strong>Отлично! Правильный перевод.</strong>';
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 4).catch(() => {});
|
|
} else {
|
|
session.errors.push({
|
|
type: 'vocab',
|
|
term: card.term,
|
|
translation: card.translation,
|
|
context: card.context_sentence || '',
|
|
user_answer: selectedText,
|
|
mode: 'quiz'
|
|
});
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-wrong';
|
|
fb.innerHTML = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 1).catch(() => {});
|
|
}
|
|
|
|
if (nextBar) nextBar.classList.remove('hidden');
|
|
if (nextBtn) nextBtn.focus();
|
|
};
|
|
|
|
optBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => handleOptionClick(btn));
|
|
});
|
|
|
|
const nextAction = () => {
|
|
cardIdx++;
|
|
renderCurrentCard();
|
|
};
|
|
|
|
document.getElementById('lesson-quiz-next-btn')?.addEventListener('click', nextAction);
|
|
|
|
session.currentStepKeyHandler = (e) => {
|
|
if (!stepActive) return;
|
|
if (!answered) {
|
|
if (['1', '2', '3', '4'].includes(e.key)) {
|
|
e.preventDefault();
|
|
const n = parseInt(e.key, 10);
|
|
if (optBtns[n - 1]) handleOptionClick(optBtns[n - 1]);
|
|
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
|
|
e.preventDefault();
|
|
playCardAudio();
|
|
}
|
|
} else {
|
|
if (e.key === 'Enter' || e.code === 'Space') {
|
|
e.preventDefault();
|
|
nextAction();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode B: Traditional Flashcards
|
|
// -----------------------------------------------------------------------
|
|
function renderFlashcardCard(card) {
|
|
let isFlipped = false;
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box lesson-flashcard-card">
|
|
<div class="step-mini-header">
|
|
<div class="step-tags-wrap">
|
|
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
|
<span class="badge badge-outline">📇 Карточки</span>
|
|
</div>
|
|
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
|
</div>
|
|
|
|
<div class="step-term-display mt-3">
|
|
<h2 class="display-term">${escapeHtml(card.term)}</h2>
|
|
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
|
|
</div>
|
|
|
|
${card.context_sentence ? `
|
|
<div class="step-context-box mt-3">
|
|
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Reveal / Options -->
|
|
<div id="vocab-answer-box" class="mt-4">
|
|
<button id="vocab-show-btn" class="btn btn-outline btn-lg w-100">
|
|
👁️ Показать перевод (Пробел)
|
|
</button>
|
|
</div>
|
|
|
|
<div id="vocab-rating-box" class="srs-actions-grid hidden mt-4">
|
|
<div class="translation-reveal-banner mb-3">
|
|
<div class="tr-label text-muted text-sm">Перевод:</div>
|
|
<strong class="tr-text">${escapeHtml(card.translation)}</strong>
|
|
${card.definition_en ? `<div class="tr-definition text-muted mt-1">${escapeHtml(card.definition_en)}</div>` : ''}
|
|
</div>
|
|
<div class="rating-buttons-row">
|
|
<button class="btn btn-danger srs-btn-rating" data-rating="1">
|
|
<span class="rating-key">1</span>
|
|
<span class="rating-label">Снова</span>
|
|
</button>
|
|
<button class="btn btn-warning srs-btn-rating" data-rating="2">
|
|
<span class="rating-key">2</span>
|
|
<span class="rating-label">Трудно</span>
|
|
</button>
|
|
<button class="btn btn-primary srs-btn-rating" data-rating="3">
|
|
<span class="rating-key">3</span>
|
|
<span class="rating-label">Хорошо</span>
|
|
</button>
|
|
<button class="btn btn-success srs-btn-rating" data-rating="4">
|
|
<span class="rating-key">4</span>
|
|
<span class="rating-label">Легко</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const playCardAudio = () => {
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
else playTextSpeech(card.term, 'en');
|
|
};
|
|
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
|
|
|
const flipCard = () => {
|
|
if (isFlipped) return;
|
|
isFlipped = true;
|
|
document.getElementById('vocab-answer-box')?.classList.add('hidden');
|
|
document.getElementById('vocab-rating-box')?.classList.remove('hidden');
|
|
playCardAudio();
|
|
};
|
|
|
|
document.getElementById('vocab-show-btn')?.addEventListener('click', flipCard);
|
|
|
|
const handleRating = async (rating) => {
|
|
if (rating >= 3) {
|
|
correctCount++;
|
|
} else {
|
|
session.errors.push({
|
|
type: 'vocab',
|
|
term: card.term,
|
|
translation: card.translation,
|
|
context: card.context_sentence || '',
|
|
rating: rating,
|
|
mode: 'flashcards'
|
|
});
|
|
}
|
|
|
|
api.srs.submitReview(card.id, rating).catch(() => {});
|
|
|
|
cardIdx++;
|
|
renderCurrentCard();
|
|
};
|
|
|
|
container.querySelectorAll('.srs-btn-rating').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const r = parseInt(btn.dataset.rating, 10);
|
|
handleRating(r);
|
|
});
|
|
});
|
|
|
|
session.currentStepKeyHandler = (e) => {
|
|
if (!stepActive) return;
|
|
if (!isFlipped) {
|
|
if (e.code === 'Space' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
flipCard();
|
|
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
|
|
e.preventDefault();
|
|
playCardAudio();
|
|
}
|
|
} else {
|
|
if (['1', '2', '3', '4'].includes(e.key)) {
|
|
e.preventDefault();
|
|
handleRating(parseInt(e.key, 10));
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode C: Active Typing
|
|
// -----------------------------------------------------------------------
|
|
function renderTypingCard(card) {
|
|
let answered = false;
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box lesson-typing-card">
|
|
<div class="step-mini-header">
|
|
<div class="step-tags-wrap">
|
|
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
|
<span class="badge badge-outline">✍️ Набор слова</span>
|
|
</div>
|
|
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
|
</div>
|
|
|
|
<div class="step-prompt-display text-center mt-3">
|
|
<span class="text-muted text-sm">Переведите на английский:</span>
|
|
<h2 class="display-term display-prompt">${escapeHtml(cleanPromptTranslation(card.translation))}</h2>
|
|
</div>
|
|
|
|
${card.context_sentence ? `
|
|
<div class="step-context-box mt-3 text-center">
|
|
<em class="context-quote">"${escapeHtml(card.context_sentence.replace(new RegExp(escapeRegex(card.term), 'gi'), '_____'))}"</em>
|
|
</div>
|
|
` : ''}
|
|
|
|
<form id="lesson-typing-form" class="mt-4">
|
|
<div class="typing-input-wrap">
|
|
<input type="text" id="lesson-typing-input" class="input-styled input-lg typing-main-input"
|
|
placeholder="Введите английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
|
|
<button type="submit" id="lesson-typing-submit-btn" class="btn btn-primary btn-lg">
|
|
Проверить (Enter)
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div id="lesson-typing-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
|
|
|
<div id="lesson-typing-next-bar" class="hidden mt-3">
|
|
<button id="lesson-typing-next-btn" class="btn btn-primary btn-lg w-100">
|
|
Продолжить ➔ (Enter или Пробел)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const inputEl = document.getElementById('lesson-typing-input');
|
|
inputEl?.focus();
|
|
|
|
const playCardAudio = () => {
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
else playTextSpeech(card.term, 'en');
|
|
};
|
|
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
|
|
|
const checkAnswer = () => {
|
|
if (answered) return;
|
|
answered = true;
|
|
|
|
const userVal = inputEl?.value.trim().toLowerCase() || '';
|
|
const targetVal = card.term.trim().toLowerCase();
|
|
const isMatch = userVal === targetVal;
|
|
|
|
if (inputEl) inputEl.disabled = true;
|
|
playCardAudio();
|
|
|
|
const fb = document.getElementById('lesson-typing-feedback');
|
|
const nextBar = document.getElementById('lesson-typing-next-bar');
|
|
const nextBtn = document.getElementById('lesson-typing-next-btn');
|
|
|
|
if (isMatch) {
|
|
correctCount++;
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-correct';
|
|
fb.innerHTML = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 4).catch(() => {});
|
|
} else {
|
|
session.errors.push({
|
|
type: 'vocab',
|
|
term: card.term,
|
|
translation: card.translation,
|
|
user_answer: userVal,
|
|
mode: 'typing'
|
|
});
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-wrong';
|
|
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 1).catch(() => {});
|
|
}
|
|
|
|
if (nextBar) nextBar.classList.remove('hidden');
|
|
if (nextBtn) nextBtn.focus();
|
|
};
|
|
|
|
document.getElementById('lesson-typing-form')?.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
checkAnswer();
|
|
});
|
|
|
|
const nextAction = () => {
|
|
cardIdx++;
|
|
renderCurrentCard();
|
|
};
|
|
|
|
document.getElementById('lesson-typing-next-btn')?.addEventListener('click', nextAction);
|
|
|
|
session.currentStepKeyHandler = (e) => {
|
|
if (!stepActive) return;
|
|
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
|
|
e.preventDefault();
|
|
nextAction();
|
|
}
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode D: Dictation
|
|
// -----------------------------------------------------------------------
|
|
function renderDictationCard(card) {
|
|
let answered = false;
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box lesson-dictation-card">
|
|
<div class="step-mini-header">
|
|
<div class="step-tags-wrap">
|
|
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
|
<span class="badge badge-outline">🎧 Диктант</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dictation-hero-play text-center mt-3">
|
|
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
|
|
🔊 Прослушать слово (Пробел)
|
|
</button>
|
|
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(cleanPromptTranslation(card.translation))}</p>` : ''}
|
|
</div>
|
|
|
|
<form id="lesson-dictation-form" class="mt-4">
|
|
<div class="typing-input-wrap">
|
|
<input type="text" id="lesson-dictation-input" class="input-styled input-lg typing-main-input"
|
|
placeholder="Запишите услышанное английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
|
|
<button type="submit" class="btn btn-primary btn-lg">
|
|
Проверить (Enter)
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div id="lesson-dictation-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
|
|
|
<div id="lesson-dictation-next-bar" class="hidden mt-3">
|
|
<button id="lesson-dictation-next-btn" class="btn btn-primary btn-lg w-100">
|
|
Продолжить ➔ (Enter или Пробел)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const playCardAudio = () => {
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
else playTextSpeech(card.term, 'en');
|
|
};
|
|
|
|
setTimeout(playCardAudio, 200);
|
|
document.getElementById('dictation-big-audio-btn')?.addEventListener('click', playCardAudio);
|
|
|
|
const inputEl = document.getElementById('lesson-dictation-input');
|
|
inputEl?.focus();
|
|
|
|
const checkAnswer = () => {
|
|
if (answered) return;
|
|
answered = true;
|
|
|
|
const userVal = inputEl?.value.trim().toLowerCase() || '';
|
|
const targetVal = card.term.trim().toLowerCase();
|
|
const isMatch = userVal === targetVal;
|
|
|
|
if (inputEl) inputEl.disabled = true;
|
|
playCardAudio();
|
|
|
|
const fb = document.getElementById('lesson-dictation-feedback');
|
|
const nextBar = document.getElementById('lesson-dictation-next-bar');
|
|
const nextBtn = document.getElementById('lesson-dictation-next-btn');
|
|
|
|
if (isMatch) {
|
|
correctCount++;
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-correct';
|
|
fb.innerHTML = `✅ <strong>Верно на слух!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 4).catch(() => {});
|
|
} else {
|
|
session.errors.push({
|
|
type: 'vocab',
|
|
term: card.term,
|
|
translation: card.translation,
|
|
user_answer: userVal,
|
|
mode: 'dictation'
|
|
});
|
|
if (fb) {
|
|
fb.className = 'trainer-feedback-box feedback-wrong';
|
|
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> (${escapeHtml(card.translation)})`;
|
|
fb.classList.remove('hidden');
|
|
}
|
|
api.srs.submitReview(card.id, 1).catch(() => {});
|
|
}
|
|
|
|
if (nextBar) nextBar.classList.remove('hidden');
|
|
if (nextBtn) nextBtn.focus();
|
|
};
|
|
|
|
document.getElementById('lesson-dictation-form')?.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
checkAnswer();
|
|
});
|
|
|
|
const nextAction = () => {
|
|
cardIdx++;
|
|
renderCurrentCard();
|
|
};
|
|
|
|
document.getElementById('lesson-dictation-next-btn')?.addEventListener('click', nextAction);
|
|
|
|
session.currentStepKeyHandler = (e) => {
|
|
if (!stepActive) return;
|
|
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
|
|
e.preventDefault();
|
|
nextAction();
|
|
} else if (!answered && e.code === 'Space' && document.activeElement !== inputEl) {
|
|
e.preventDefault();
|
|
playCardAudio();
|
|
}
|
|
};
|
|
}
|
|
|
|
renderCurrentCard();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 2: Grammar Review Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runGrammarReviewStep(step, container) {
|
|
let targetUnitNum = 1;
|
|
if (step.topic_mode === 'weakest') {
|
|
targetUnitNum = grammarRecs?.weakest_unit?.unit_num || 1;
|
|
} else if (step.topic_mode === 'previous') {
|
|
targetUnitNum = grammarRecs?.previous_unit?.unit_num || 1;
|
|
} else if (step.topic_mode && step.topic_mode.startsWith('unit_')) {
|
|
targetUnitNum = parseInt(step.topic_mode.replace('unit_', ''), 10) || 1;
|
|
}
|
|
|
|
const unitDetail = await api.grammar.getUnit(targetUnitNum);
|
|
const exercises = unitDetail.exercises || [];
|
|
const limit = Math.min(step.count || 5, exercises.length);
|
|
const selectedExercises = exercises.slice(0, limit);
|
|
|
|
let exIdx = 0;
|
|
let correctCount = 0;
|
|
|
|
function renderCurrentExercise() {
|
|
if (exIdx >= selectedExercises.length) {
|
|
const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100);
|
|
session.stepResults.push({
|
|
type: 'grammar_review',
|
|
title: step.title,
|
|
score: score,
|
|
correct: correctCount,
|
|
total: selectedExercises.length,
|
|
unit_num: targetUnitNum,
|
|
unit_title: unitDetail.title
|
|
});
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center step-completed-box">
|
|
<div class="celebrate-badge">🎯</div>
|
|
<h3>Закрепление темы завершено!</h3>
|
|
<p class="unit-subtitle">Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}</p>
|
|
<p class="score-highlight">Результат: <strong>${correctCount} из ${selectedExercises.length}</strong> (${score}%)</p>
|
|
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
|
|
Перейти к следующему блоку ➔
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const ex = selectedExercises[exIdx];
|
|
container.innerHTML = `
|
|
<div class="step-card-box">
|
|
<div class="step-mini-header">
|
|
<span class="badge">Повторение: Юнит ${unitDetail.unit_num} (${exIdx + 1} из ${selectedExercises.length})</span>
|
|
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
|
|
</div>
|
|
|
|
<div class="grammar-question-box mt-3">
|
|
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
|
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="grammar-options-grid mt-4">
|
|
${(ex.options || []).map((opt, oIdx) => `
|
|
<button class="grammar-opt-btn" data-val="${escapeHtml(opt)}">
|
|
<span class="opt-key">${oIdx + 1}</span>
|
|
<span class="opt-text">${escapeHtml(opt)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div id="grammar-feedback-box" class="mt-4 hidden"></div>
|
|
</div>
|
|
`;
|
|
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
// Disable all options
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
|
|
const userChoice = btn.dataset.val;
|
|
|
|
// Submit answer to backend
|
|
let isCorrect = false;
|
|
let explanation = '';
|
|
try {
|
|
const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice);
|
|
isCorrect = ansRes.correct;
|
|
explanation = ansRes.explanation || ex.explanation || '';
|
|
} catch {
|
|
isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase();
|
|
explanation = ex.explanation || '';
|
|
}
|
|
|
|
if (isCorrect) {
|
|
btn.classList.add('opt-correct');
|
|
correctCount++;
|
|
} else {
|
|
btn.classList.add('opt-wrong');
|
|
// Highlight correct button
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(b => {
|
|
if (b.dataset.val === ex.target) b.classList.add('opt-correct');
|
|
});
|
|
|
|
// Record error
|
|
session.errors.push({
|
|
type: 'grammar',
|
|
unit_num: targetUnitNum,
|
|
unit_title: unitDetail.title,
|
|
formula: unitDetail.golden_formula,
|
|
user_answer: userChoice,
|
|
correct_answer: ex.target,
|
|
sentence: ex.sentence,
|
|
explanation: explanation
|
|
});
|
|
}
|
|
|
|
const fb = document.getElementById('grammar-feedback-box');
|
|
if (fb) {
|
|
fb.classList.remove('hidden');
|
|
fb.innerHTML = `
|
|
<div class="feedback-banner ${isCorrect ? 'fb-success' : 'fb-danger'}">
|
|
<div class="fb-title">${isCorrect ? '✅ Абсолютно верно!' : '❌ Не совсем точно'}</div>
|
|
<div class="fb-text">${escapeHtml(explanation)}</div>
|
|
<button id="next-grammar-btn" class="btn btn-primary mt-3">
|
|
${exIdx + 1 < selectedExercises.length ? 'Следующий вопрос ➔' : 'Завершить блок грамматики ➔'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('next-grammar-btn')?.addEventListener('click', () => {
|
|
exIdx++;
|
|
renderCurrentExercise();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderCurrentExercise();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 3: Grammar New Topic Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runGrammarNewStep(step, container) {
|
|
let targetUnitNum = 2;
|
|
if (step.unit_mode === 'auto_next') {
|
|
targetUnitNum = grammarRecs?.next_unit?.unit_num || 2;
|
|
} else if (step.unit_mode && step.unit_mode.startsWith('unit_')) {
|
|
targetUnitNum = parseInt(step.unit_mode.replace('unit_', ''), 10) || 2;
|
|
}
|
|
|
|
const unitDetail = await api.grammar.getUnit(targetUnitNum);
|
|
let stage = 'theory'; // theory -> exercises
|
|
|
|
function renderTheory() {
|
|
const contrasts = unitDetail.contrast_explanation || [];
|
|
container.innerHTML = `
|
|
<div class="step-card-box">
|
|
<div class="step-mini-header">
|
|
<span class="badge">Новая тема: Юнит ${unitDetail.unit_num}</span>
|
|
<span class="badge badge-primary">${escapeHtml(unitDetail.category)}</span>
|
|
</div>
|
|
|
|
<h2 class="unit-title mt-2">${escapeHtml(unitDetail.title)}</h2>
|
|
<p class="unit-subtitle">${escapeHtml(unitDetail.subtitle || '')}</p>
|
|
|
|
<!-- Golden Formula Card -->
|
|
<div class="golden-formula-box mt-3">
|
|
<div class="formula-label">⚡ Золотая формула для тетради:</div>
|
|
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
|
|
</div>
|
|
|
|
<!-- Contrast Situations -->
|
|
${contrasts.map(c => `
|
|
<div class="contrast-card mt-3">
|
|
<div class="contrast-row">
|
|
<div class="contrast-col a">
|
|
<div class="col-title">${escapeHtml(c.situation_a)}</div>
|
|
<div class="col-example">${escapeHtml(c.example_a)}</div>
|
|
<div class="col-trans">${escapeHtml(c.trans_a)}</div>
|
|
</div>
|
|
<div class="contrast-col b">
|
|
<div class="col-title">${escapeHtml(c.situation_b)}</div>
|
|
<div class="col-example">${escapeHtml(c.example_b)}</div>
|
|
<div class="col-trans">${escapeHtml(c.trans_b)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
|
|
</div>
|
|
`).join('')}
|
|
|
|
<!-- Handwritten Journaling Checkbox -->
|
|
<div class="journaling-box mt-4">
|
|
<label class="checkbox-container">
|
|
<input type="checkbox" id="hw-checkbox">
|
|
<span class="checkmark"></span>
|
|
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="mt-4 text-center">
|
|
<button id="start-new-exercises-btn" class="btn btn-primary btn-lg">
|
|
Перейти к закреплению на упражнениях ➔
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('start-new-exercises-btn')?.addEventListener('click', () => {
|
|
const hwChecked = document.getElementById('hw-checkbox')?.checked || false;
|
|
try {
|
|
api.grammar.updateHandwritten(unitDetail.id, hwChecked);
|
|
} catch {}
|
|
stage = 'exercises';
|
|
renderExercises();
|
|
});
|
|
}
|
|
|
|
function renderExercises() {
|
|
const exercises = unitDetail.exercises || [];
|
|
const limit = Math.min(step.exercises_count || 5, exercises.length);
|
|
const selectedExercises = exercises.slice(0, limit);
|
|
let exIdx = 0;
|
|
let correctCount = 0;
|
|
|
|
function renderEx() {
|
|
if (exIdx >= selectedExercises.length) {
|
|
const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100);
|
|
session.stepResults.push({
|
|
type: 'grammar_new',
|
|
title: step.title,
|
|
score: score,
|
|
correct: correctCount,
|
|
total: selectedExercises.length,
|
|
unit_num: targetUnitNum,
|
|
unit_title: unitDetail.title
|
|
});
|
|
|
|
// Record unit completion
|
|
try {
|
|
api.grammar.recordCompletion(unitDetail.id, score, true);
|
|
} catch {}
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center step-completed-box">
|
|
<div class="celebrate-badge">📘</div>
|
|
<h3>Тема успешно освоена!</h3>
|
|
<p class="unit-subtitle">Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}</p>
|
|
<p class="score-highlight">Результат практики: <strong>${correctCount} из ${selectedExercises.length}</strong> (${score}%)</p>
|
|
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
|
|
Перейти к следующему блоку ➔
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const ex = selectedExercises[exIdx];
|
|
container.innerHTML = `
|
|
<div class="step-card-box">
|
|
<div class="step-mini-header">
|
|
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
|
|
</div>
|
|
|
|
<div class="grammar-question-box mt-3">
|
|
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
|
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="grammar-options-grid mt-4">
|
|
${(ex.options || []).map((opt, oIdx) => `
|
|
<button class="grammar-opt-btn" data-val="${escapeHtml(opt)}">
|
|
<span class="opt-key">${oIdx + 1}</span>
|
|
<span class="opt-text">${escapeHtml(opt)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div id="grammar-feedback-box" class="mt-4 hidden"></div>
|
|
</div>
|
|
`;
|
|
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
|
|
const userChoice = btn.dataset.val;
|
|
|
|
let isCorrect = false;
|
|
let explanation = '';
|
|
try {
|
|
const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice);
|
|
isCorrect = ansRes.correct;
|
|
explanation = ansRes.explanation || ex.explanation || '';
|
|
} catch {
|
|
isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase();
|
|
explanation = ex.explanation || '';
|
|
}
|
|
|
|
if (isCorrect) {
|
|
btn.classList.add('opt-correct');
|
|
correctCount++;
|
|
} else {
|
|
btn.classList.add('opt-wrong');
|
|
container.querySelectorAll('.grammar-opt-btn').forEach(b => {
|
|
if (b.dataset.val === ex.target) b.classList.add('opt-correct');
|
|
});
|
|
session.errors.push({
|
|
type: 'grammar',
|
|
unit_num: targetUnitNum,
|
|
unit_title: unitDetail.title,
|
|
formula: unitDetail.golden_formula,
|
|
user_answer: userChoice,
|
|
correct_answer: ex.target,
|
|
sentence: ex.sentence,
|
|
explanation: explanation
|
|
});
|
|
}
|
|
|
|
const fb = document.getElementById('grammar-feedback-box');
|
|
if (fb) {
|
|
fb.classList.remove('hidden');
|
|
fb.innerHTML = `
|
|
<div class="feedback-banner ${isCorrect ? 'fb-success' : 'fb-danger'}">
|
|
<div class="fb-title">${isCorrect ? '✅ Верно!' : '❌ Ошибка в форме'}</div>
|
|
<div class="fb-text">${escapeHtml(explanation)}</div>
|
|
<button id="next-ex-btn" class="btn btn-primary mt-3">
|
|
${exIdx + 1 < selectedExercises.length ? 'Следующее задание ➔' : 'Завершить практику темы ➔'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('next-ex-btn')?.addEventListener('click', () => {
|
|
exIdx++;
|
|
renderEx();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderEx();
|
|
}
|
|
|
|
renderTheory();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 4: Listening Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runListeningStep(step, container) {
|
|
const totalScenarios = step.count || 1;
|
|
let scenarioIdx = 0;
|
|
let correctCount = 0;
|
|
|
|
async function renderScenario() {
|
|
if (scenarioIdx >= totalScenarios) {
|
|
const score = Math.round((correctCount / Math.max(1, totalScenarios)) * 100);
|
|
session.stepResults.push({
|
|
type: 'listening',
|
|
title: step.title,
|
|
score: score,
|
|
correct: correctCount,
|
|
total: totalScenarios
|
|
});
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center step-completed-box">
|
|
<div class="celebrate-badge">🎧</div>
|
|
<h3>Аудирование завершено!</h3>
|
|
<p class="score-highlight">Результат: <strong>${correctCount} из ${totalScenarios}</strong> (${score}%)</p>
|
|
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
|
|
Перейти к следующему блоку ➔
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const data = await api.listening.getExercise(step.topic || 'all');
|
|
const scenario = (data && (data.scenario || data)) || {};
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box">
|
|
<div class="step-mini-header">
|
|
<span class="badge">Ситуация ${scenarioIdx + 1} из ${totalScenarios}</span>
|
|
<span class="badge badge-primary">${escapeHtml(scenario.topic || 'General')} (${escapeHtml(scenario.level || 'B1')})</span>
|
|
</div>
|
|
|
|
<div class="audio-player-card mt-3">
|
|
<div class="audio-controls-row">
|
|
<button id="play-story-btn" class="btn btn-primary btn-lg">
|
|
▶ Послушать ситуацию
|
|
</button>
|
|
<button id="audio-speed-btn" class="btn btn-outline">
|
|
Скорость: 1.0x
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="listening-question-section mt-4">
|
|
<div class="question-header-row">
|
|
<h4>Вопрос диктора:</h4>
|
|
<button id="play-question-btn" class="icon-btn" title="Озвучить вопрос">🔊</button>
|
|
</div>
|
|
<p class="question-en">"${escapeHtml(scenario.question)}"</p>
|
|
<p class="question-ru text-muted">(${escapeHtml(scenario.question_ru || '')})</p>
|
|
|
|
<div class="answer-input-group mt-3">
|
|
<input type="text" id="listening-answer-input" class="input-styled"
|
|
placeholder="Введите услышанный ответ на английском...">
|
|
<button id="submit-listening-btn" class="btn btn-success">Проверить</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="listening-feedback-box" class="mt-4 hidden"></div>
|
|
</div>
|
|
`;
|
|
|
|
let storyAudio = scenario.story_audio_url ? new Audio(scenario.story_audio_url) : null;
|
|
let questionAudio = scenario.question_audio_url ? new Audio(scenario.question_audio_url) : null;
|
|
let currentPlaybackRate = 1.0;
|
|
|
|
document.getElementById('play-story-btn')?.addEventListener('click', () => {
|
|
if (storyAudio) {
|
|
storyAudio.playbackRate = currentPlaybackRate;
|
|
storyAudio.play().catch(() => {});
|
|
}
|
|
});
|
|
|
|
document.getElementById('play-question-btn')?.addEventListener('click', () => {
|
|
if (questionAudio) {
|
|
questionAudio.playbackRate = currentPlaybackRate;
|
|
questionAudio.play().catch(() => {});
|
|
}
|
|
});
|
|
|
|
document.getElementById('audio-speed-btn')?.addEventListener('click', (e) => {
|
|
currentPlaybackRate = currentPlaybackRate === 1.0 ? 0.8 : 1.0;
|
|
e.target.textContent = `Скорость: ${currentPlaybackRate}x`;
|
|
if (storyAudio) storyAudio.playbackRate = currentPlaybackRate;
|
|
if (questionAudio) questionAudio.playbackRate = currentPlaybackRate;
|
|
});
|
|
|
|
document.getElementById('submit-listening-btn')?.addEventListener('click', async () => {
|
|
const input = document.getElementById('listening-answer-input');
|
|
const userAns = input?.value.trim() || '';
|
|
|
|
const res = await api.listening.checkAnswer({
|
|
scenario_id: scenario.id,
|
|
user_answer: userAns
|
|
});
|
|
|
|
if (res.is_correct) {
|
|
correctCount++;
|
|
} else {
|
|
session.errors.push({
|
|
type: 'listening',
|
|
question: scenario.question,
|
|
user_answer: userAns,
|
|
ideal_answer: scenario.ideal_answer,
|
|
topic: scenario.topic
|
|
});
|
|
}
|
|
|
|
const fb = document.getElementById('listening-feedback-box');
|
|
if (fb) {
|
|
fb.classList.remove('hidden');
|
|
fb.innerHTML = `
|
|
<div class="feedback-banner ${res.is_correct ? 'fb-success' : 'fb-danger'}">
|
|
<div class="fb-title">${escapeHtml(res.feedback)}</div>
|
|
<div class="ideal-answer-box mt-2">
|
|
<strong>Правильный ответ:</strong> ${escapeHtml(scenario.ideal_answer)}
|
|
</div>
|
|
<div class="transcript-box mt-3">
|
|
<details>
|
|
<summary>Показать полный текст истории (транскрипт)</summary>
|
|
<p class="mt-2"><strong>EN:</strong> ${escapeHtml(scenario.story)}</p>
|
|
<p class="text-muted"><strong>RU:</strong> ${escapeHtml(scenario.story_ru)}</p>
|
|
</details>
|
|
</div>
|
|
<button id="next-listening-btn" class="btn btn-primary mt-3">
|
|
${scenarioIdx + 1 < totalScenarios ? 'Следующая ситуация ➔' : 'Завершить аудирование ➔'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('next-listening-btn')?.addEventListener('click', () => {
|
|
scenarioIdx++;
|
|
renderScenario();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
await renderScenario();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 5: Reading & Vocabulary Miner Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runReadingStep(step, container) {
|
|
let article = null;
|
|
if (step.article_id && step.article_id !== 'random') {
|
|
article = articlesList.find(a => a.id === step.article_id);
|
|
}
|
|
if (!article && articlesList.length > 0) {
|
|
article = articlesList[Math.floor(Math.random() * articlesList.length)];
|
|
}
|
|
if (!article) {
|
|
article = {
|
|
title: "Exploring New Horizons",
|
|
title_ru: "Исследуя новые горизонты",
|
|
topic: "Science",
|
|
paragraphs: [
|
|
"Scientific exploration expands the boundaries of human knowledge through curious observation and rigorous testing.",
|
|
"Every breakthrough in technology begins with an inquisitive mind willing to question established assumptions."
|
|
],
|
|
paragraphs_ru: [
|
|
"Научные исследования расширяют границы человеческих знаний благодаря любознательному наблюдению и строгой проверке.",
|
|
"Каждый прорыв в технологиях начинается с пытливого ума, готового подвергнуть сомнению устоявшиеся предположения."
|
|
],
|
|
vocabulary_highlights: [
|
|
{"term": "boundaries", "trans": "границы"},
|
|
{"term": "rigorous", "trans": "строгий, тщательный"},
|
|
{"term": "inquisitive", "trans": "пытливый, любознательный"}
|
|
]
|
|
};
|
|
}
|
|
|
|
let wordsSavedCount = 0;
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box">
|
|
<div class="step-mini-header">
|
|
<span class="badge">Чтение и пополнение словаря</span>
|
|
<span class="badge badge-primary">${escapeHtml(article.topic || 'Article')}</span>
|
|
</div>
|
|
|
|
<h2 class="article-title mt-2">${escapeHtml(article.title)}</h2>
|
|
<p class="article-title-ru text-muted">${escapeHtml(article.title_ru || '')}</p>
|
|
|
|
<div class="reader-content-box mt-3" id="lesson-reader-text">
|
|
${article.paragraphs.map((p, pIdx) => `
|
|
<div class="article-paragraph-card">
|
|
<p class="article-para-en" data-pidx="${pIdx}">
|
|
${p.split(' ').map(w => `<span class="clickable-reader-word" data-word="${escapeHtml(w.replace(/[^\w'-]/g, ''))}">${escapeHtml(w)}</span>`).join(' ')}
|
|
</p>
|
|
<div class="article-para-ru-wrap">
|
|
<button class="btn btn-xs btn-ghost toggle-para-trans-btn" data-pidx="${pIdx}">
|
|
👁️ Показать перевод абзаца
|
|
</button>
|
|
<p class="article-para-ru hidden" id="para-ru-${pIdx}">
|
|
${escapeHtml(article.paragraphs_ru?.[pIdx] || '')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<!-- Vocabulary Highlights Bar -->
|
|
${article.vocabulary_highlights?.length ? `
|
|
<div class="vocab-highlights-bar mt-4">
|
|
<span class="highlights-title">💡 Ключевые выражения из статьи:</span>
|
|
<div class="highlights-chips">
|
|
${article.vocabulary_highlights.map(vh => `
|
|
<span class="vh-chip" data-term="${escapeHtml(vh.term)}" data-trans="${escapeHtml(vh.trans)}">
|
|
<strong>${escapeHtml(vh.term)}</strong> — ${escapeHtml(vh.trans)}
|
|
</span>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Reading Word Lookup Popup -->
|
|
<div id="lesson-word-popup" class="lesson-popup hidden">
|
|
<div class="popup-header">
|
|
<strong id="popup-term">Word</strong>
|
|
<button class="popup-close-btn" id="popup-close-btn">✕</button>
|
|
</div>
|
|
<div class="popup-body">
|
|
<div id="popup-trans" class="popup-trans">Перевод...</div>
|
|
<div id="popup-phonetic" class="popup-phonetic text-muted"></div>
|
|
</div>
|
|
<div class="popup-actions mt-2">
|
|
<button id="popup-save-btn" class="btn btn-sm btn-success w-100">+ Добавить в мой словарь</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 text-center">
|
|
<button id="finish-reading-step-btn" class="btn btn-primary btn-lg">
|
|
Завершить чтение и перейти к ИИ-Итогу ➔
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Toggle paragraph translations
|
|
container.querySelectorAll('.toggle-para-trans-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const pIdx = btn.dataset.pidx;
|
|
const paraRu = document.getElementById(`para-ru-${pIdx}`);
|
|
if (paraRu) {
|
|
paraRu.classList.toggle('hidden');
|
|
btn.textContent = paraRu.classList.contains('hidden') ? '👁️ Показать перевод абзаца' : '🙈 Скрыть перевод';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Word popup interaction
|
|
const popup = document.getElementById('lesson-word-popup');
|
|
let selectedWordData = null;
|
|
|
|
container.querySelectorAll('.clickable-reader-word').forEach(span => {
|
|
span.addEventListener('click', async (e) => {
|
|
const term = span.dataset.word;
|
|
if (!term || term.length < 2) return;
|
|
|
|
const rect = span.getBoundingClientRect();
|
|
popup.style.top = `${rect.bottom + window.scrollY + 6}px`;
|
|
popup.style.left = `${Math.min(window.innerWidth - 260, Math.max(10, rect.left))}px`;
|
|
popup.classList.remove('hidden');
|
|
|
|
document.getElementById('popup-term').textContent = term;
|
|
document.getElementById('popup-trans').textContent = 'Поиск в словаре...';
|
|
|
|
try {
|
|
const lookupRes = await api.lookup.search(term);
|
|
selectedWordData = {
|
|
term: term,
|
|
translation: lookupRes.translation || 'Перевод',
|
|
phonetic: lookupRes.phonetic || '',
|
|
pos: lookupRes.pos || '',
|
|
context_sentence: span.closest('.article-para-en')?.textContent.trim() || ''
|
|
};
|
|
|
|
document.getElementById('popup-trans').textContent = selectedWordData.translation;
|
|
document.getElementById('popup-phonetic').textContent = selectedWordData.phonetic ? `/${selectedWordData.phonetic}/` : '';
|
|
} catch {
|
|
document.getElementById('popup-trans').textContent = 'Перевод не найден';
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById('popup-close-btn')?.addEventListener('click', () => {
|
|
popup?.classList.add('hidden');
|
|
});
|
|
|
|
document.getElementById('popup-save-btn')?.addEventListener('click', async () => {
|
|
if (!selectedWordData) return;
|
|
try {
|
|
await api.cards.create({
|
|
term: selectedWordData.term,
|
|
translation: selectedWordData.translation,
|
|
phonetic: selectedWordData.phonetic,
|
|
part_of_speech: selectedWordData.pos,
|
|
context_sentence: selectedWordData.context_sentence,
|
|
collection: 'General',
|
|
tags: 'reading'
|
|
});
|
|
wordsSavedCount++;
|
|
ui.showToast(`✅ Слово «${selectedWordData.term}» сохранено в словарь!`, 'success');
|
|
popup.classList.add('hidden');
|
|
} catch (e) {
|
|
ui.showToast(`Ошибка сохранения: ${e.message}`, 'error');
|
|
}
|
|
});
|
|
|
|
document.getElementById('finish-reading-step-btn')?.addEventListener('click', () => {
|
|
session.stepResults.push({
|
|
type: 'reading',
|
|
title: step.title,
|
|
score: 100,
|
|
words_saved: wordsSavedCount,
|
|
article_title: article.title
|
|
});
|
|
advanceStep();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 6: AI Debrief Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runAiDebriefStep(step, container) {
|
|
clearInterval(session.timerInterval);
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box text-center">
|
|
<div class="ai-loader-box">
|
|
<div class="spinner spinner-lg"></div>
|
|
<h3 class="mt-3">🤖 Локальный ИИ Qwen 2.5 формирует ваш персональный разбор...</h3>
|
|
<p class="text-muted">Анализируем правильные ответы, паттерны ошибок и подбираем рекомендации по грамматике.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Calculate total score
|
|
let totalScore = 100;
|
|
if (session.stepResults.length > 0) {
|
|
const sum = session.stepResults.reduce((acc, s) => acc + (s.score || 0), 0);
|
|
totalScore = Math.round(sum / session.stepResults.length);
|
|
}
|
|
|
|
const payload = {
|
|
preset_name: session.presetName,
|
|
duration_seconds: session.elapsedSeconds,
|
|
total_score: totalScore,
|
|
steps: session.stepResults,
|
|
errors: session.errors
|
|
};
|
|
|
|
let report = null;
|
|
try {
|
|
const res = await api.lessons.submitDebrief(payload);
|
|
report = res.ai_report;
|
|
} catch (e) {
|
|
console.error('Debrief error:', e);
|
|
report = {
|
|
grade: totalScore >= 75 ? 'A' : 'B',
|
|
score: totalScore,
|
|
headline: 'Урок успешно завершён!',
|
|
strengths: ['Вы успешно прошли все запланированные блоки.'],
|
|
error_patterns: ['Ошибки зафиксированы и добавлены в повторение.'],
|
|
tomorrow_focus: ['Продолжайте ежедневную практику.']
|
|
};
|
|
}
|
|
|
|
renderFinalDebriefCard(container, report, totalScore);
|
|
}
|
|
|
|
function renderFinalDebriefCard(container, report, totalScore) {
|
|
const mins = Math.max(1, Math.round(session.elapsedSeconds / 60));
|
|
|
|
container.innerHTML = `
|
|
<div class="step-card-box lesson-debrief-card">
|
|
<!-- Header Banner -->
|
|
<div class="debrief-banner-header">
|
|
<div class="grade-badge-wrap">
|
|
<span class="grade-badge grade-${report.grade?.replace('+', '-plus') || 'a'}">${escapeHtml(report.grade || 'A')}</span>
|
|
<span class="score-pct">${totalScore}%</span>
|
|
</div>
|
|
<div class="debrief-titles">
|
|
<h2 class="debrief-headline">${escapeHtml(report.headline || 'Отличная работа!')}</h2>
|
|
<div class="debrief-meta text-muted">
|
|
⏱️ Время сессии: <strong>${mins} мин</strong> | 🎯 Блоков: <strong>${session.stepResults.length}</strong> | ❌ Ошибок: <strong>${session.errors.length}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Block by Block Progress -->
|
|
<div class="debrief-blocks-breakdown mt-4">
|
|
<h4>📊 Результаты по блокам урока:</h4>
|
|
<div class="blocks-scores-grid mt-2">
|
|
${session.stepResults.map(s => `
|
|
<div class="block-score-item">
|
|
<div class="bs-header">
|
|
<span class="bs-title">${getStepIcon(s.type)} ${escapeHtml(s.title || s.type)}</span>
|
|
<span class="bs-val">${s.score}%</span>
|
|
</div>
|
|
<div class="bs-bar">
|
|
<div class="bs-bar-fill" style="width: ${s.score}%"></div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Strengths -->
|
|
${report.strengths?.length ? `
|
|
<div class="debrief-section mt-4">
|
|
<h4>🌟 Что получилось лучше всего:</h4>
|
|
<ul class="debrief-list">
|
|
${report.strengths.map(st => `<li>${escapeHtml(st)}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Error Patterns & Pedagogic Explanations -->
|
|
${report.error_patterns?.length ? `
|
|
<div class="debrief-section error-patterns-section mt-4">
|
|
<h4>🔍 Педагогический анализ ошибок:</h4>
|
|
<div class="error-cards-list mt-2">
|
|
${report.error_patterns.map(ep => `
|
|
<div class="error-analysis-item">
|
|
<span class="err-icon">💡</span>
|
|
<div class="err-text">${escapeHtml(ep)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Focus for Tomorrow -->
|
|
${report.tomorrow_focus?.length ? `
|
|
<div class="debrief-section tomorrow-focus-section mt-4">
|
|
<h4>🎯 Персональный фокус на завтра:</h4>
|
|
<ul class="tomorrow-focus-list">
|
|
${report.tomorrow_focus.map(tf => `<li>👉 ${escapeHtml(tf)}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="debrief-actions-row mt-5 text-center">
|
|
<button id="debrief-finish-btn" class="btn btn-primary btn-lg">
|
|
🏁 Завершить урок и вернуться в меню
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('debrief-finish-btn')?.addEventListener('click', () => {
|
|
session.active = false;
|
|
ui.switchView('lesson-view');
|
|
renderConfigurator();
|
|
});
|
|
}
|
|
|
|
function finishSession() {
|
|
session.active = false;
|
|
clearInterval(session.timerInterval);
|
|
ui.switchView('lesson-view');
|
|
renderConfigurator();
|
|
}
|
|
|
|
async function openHistoryModal() {
|
|
try {
|
|
const data = await api.lessons.getHistory(15);
|
|
const modal = document.getElementById('lesson-history-modal');
|
|
if (!modal) return;
|
|
|
|
const list = document.getElementById('lesson-history-list');
|
|
if (list) {
|
|
if (!data.history || data.history.length === 0) {
|
|
list.innerHTML = `<div class="empty-state">История комплексных уроков пока пуста. Пройдите ваш первый урок!</div>`;
|
|
} else {
|
|
list.innerHTML = data.history.map(h => `
|
|
<div class="lesson-history-item">
|
|
<div class="lh-header">
|
|
<span class="lh-preset">🚀 ${escapeHtml(h.preset_name)}</span>
|
|
<span class="lh-date">${h.completed_at}</span>
|
|
</div>
|
|
<div class="lh-stats">
|
|
<span class="lh-badge grade-${h.grade?.toLowerCase() || 'a'}">${h.grade} (${h.total_score}%)</span>
|
|
<span class="lh-time">⏱️ ${Math.round((h.duration_seconds || 0) / 60)} мин</span>
|
|
<span class="lh-errors">❌ ${h.errors_count} ошибок</span>
|
|
</div>
|
|
${h.headline ? `<div class="lh-headline">"${escapeHtml(h.headline)}"</div>` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
modal.classList.remove('hidden');
|
|
document.getElementById('lesson-history-close-btn')?.addEventListener('click', () => {
|
|
modal.classList.add('hidden');
|
|
}, { once: true });
|
|
} catch (e) {
|
|
ui.showToast(`Ошибка загрузки истории: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|