feat: background tasks manager and book sentence mining for CEFR tests
This commit is contained in:
@@ -316,5 +316,41 @@ export const api = {
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to update handwritten status: ${resp.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Background Tasks & Sentence Mining
|
||||
tasks: {
|
||||
async mineBooks(bookId = null, level = 'A1', count = 15) {
|
||||
const resp = await fetch('/api/tasks/mine-books', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
book_id: bookId,
|
||||
level: level,
|
||||
count: count
|
||||
})
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to start mining task: ${resp.status}`);
|
||||
},
|
||||
async getActive(taskType = null) {
|
||||
let url = '/api/tasks/active';
|
||||
if (taskType) url += `?task_type=${encodeURIComponent(taskType)}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to get active task: ${resp.status}`);
|
||||
},
|
||||
async getStatus(taskId) {
|
||||
const resp = await fetch(`/api/tasks/${taskId}`);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to get task status: ${resp.status}`);
|
||||
},
|
||||
async cancel(taskId) {
|
||||
const resp = await fetch(`/api/tasks/${taskId}/cancel`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to cancel task: ${resp.status}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,6 +64,59 @@ export function initCefr() {
|
||||
});
|
||||
}
|
||||
|
||||
// Mine books modal setup
|
||||
const mineBtn = document.getElementById('cefr-mine-books-btn');
|
||||
const mineModal = document.getElementById('mine-books-modal');
|
||||
const closeMineBtn = document.getElementById('close-mine-books-modal-btn');
|
||||
const cancelMineBtn = document.getElementById('cancel-mine-books-btn');
|
||||
const mineForm = document.getElementById('mine-books-form');
|
||||
const bookSelect = document.getElementById('mine-books-select');
|
||||
|
||||
if (mineBtn && mineModal) {
|
||||
mineBtn.addEventListener('click', async () => {
|
||||
if (bookSelect) {
|
||||
try {
|
||||
const books = await api.books.getAll();
|
||||
let optionsHtml = '<option value="">📚 Все книги библиотеки</option>';
|
||||
books.forEach(b => {
|
||||
optionsHtml += `<option value="${b.id}">📖 ${escapeHtml(b.title)}${b.author ? ` (${escapeHtml(b.author)})` : ''}</option>`;
|
||||
});
|
||||
bookSelect.innerHTML = optionsHtml;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load books for select:', e);
|
||||
}
|
||||
}
|
||||
mineModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const closeMineModal = () => mineModal.classList.add('hidden');
|
||||
if (closeMineBtn) closeMineBtn.addEventListener('click', closeMineModal);
|
||||
if (cancelMineBtn) cancelMineBtn.addEventListener('click', closeMineModal);
|
||||
|
||||
if (mineForm) {
|
||||
mineForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const bookIdVal = document.getElementById('mine-books-select')?.value;
|
||||
const bookId = bookIdVal ? parseInt(bookIdVal, 10) : null;
|
||||
const level = document.getElementById('mine-level-select')?.value || 'ALL';
|
||||
const count = parseInt(document.getElementById('mine-count-input')?.value || '15', 10);
|
||||
|
||||
closeMineModal();
|
||||
try {
|
||||
const res = await api.tasks.mineBooks(bookId, level, count);
|
||||
showToast(res.message || 'Сборка тестов запущена в фоне', 'info');
|
||||
if (res.task_id) {
|
||||
startTaskPolling(res.task_id);
|
||||
} else {
|
||||
checkActiveMiningTask();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Ошибка запуска: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation (1-4 for options, Space/Enter for next)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const cefrView = document.getElementById('cefr-view');
|
||||
@@ -90,6 +143,106 @@ export function initCefr() {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Background Tasks Polling & Banner Controls
|
||||
// -----------------------------------------------------------------------
|
||||
let activeTaskPollTimer = null;
|
||||
|
||||
export async function checkActiveMiningTask() {
|
||||
try {
|
||||
const res = await api.tasks.getActive('mine_books');
|
||||
const task = res.task;
|
||||
if (task && ['pending', 'running'].includes(task.status)) {
|
||||
renderTaskBanner(task);
|
||||
startTaskPolling(task.id);
|
||||
} else {
|
||||
hideTaskBanner();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check active task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function startTaskPolling(taskId) {
|
||||
if (activeTaskPollTimer) clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await api.tasks.getStatus(taskId);
|
||||
const task = res.task;
|
||||
if (!task) {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.status === 'completed') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast(task.message || 'Сборка тестов завершена!', 'success');
|
||||
loadCefrOverview();
|
||||
} else if (task.status === 'cancelled') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast('Сборка тестов отменена', 'info');
|
||||
} else if (task.status === 'failed') {
|
||||
clearInterval(activeTaskPollTimer);
|
||||
activeTaskPollTimer = null;
|
||||
hideTaskBanner();
|
||||
showToast(`Ошибка сборки: ${task.error_message || task.message}`, 'error');
|
||||
} else {
|
||||
renderTaskBanner(task);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Poll error:', err);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function renderTaskBanner(task) {
|
||||
const banner = document.getElementById('cefr-task-banner');
|
||||
if (!banner) return;
|
||||
const pct = task.progress_percent || 0;
|
||||
banner.innerHTML = `
|
||||
<div class="task-banner-inner">
|
||||
<div class="task-banner-content">
|
||||
<div class="task-banner-title">
|
||||
<span class="spinner-sm"></span>
|
||||
<strong>⚡ Фоновая сборка тестов из книг</strong>
|
||||
<span class="task-banner-counts">${task.progress_current} / ${task.progress_total} (${pct}%)</span>
|
||||
</div>
|
||||
<div class="task-banner-msg">${escapeHtml(task.message || 'Анализ предложений...')}</div>
|
||||
<div class="progress-bar-wrap" style="height: 6px; margin-top: 6px;">
|
||||
<div class="progress-bar-fill level-badge-b1" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-banner-action">
|
||||
<button type="button" class="btn btn-xs btn-outline task-cancel-btn" data-task-id="${task.id}">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
banner.classList.remove('hidden');
|
||||
|
||||
banner.querySelector('.task-cancel-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.tasks.cancel(task.id);
|
||||
showToast('Запрос на отмену отправлен...', 'info');
|
||||
} catch (e) {
|
||||
showToast('Не удалось отменить задачу', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hideTaskBanner() {
|
||||
const banner = document.getElementById('cefr-task-banner');
|
||||
if (banner) {
|
||||
banner.classList.add('hidden');
|
||||
banner.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screen Switcher inside #cefr-view
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -108,6 +261,7 @@ function showCefrScreen(screenName) {
|
||||
// -----------------------------------------------------------------------
|
||||
export async function loadCefrOverview() {
|
||||
showCefrScreen('overview');
|
||||
checkActiveMiningTask();
|
||||
const grid = document.getElementById('cefr-levels-grid');
|
||||
if (!grid) return;
|
||||
|
||||
@@ -312,6 +466,7 @@ function renderCurrentQuestion() {
|
||||
<div class="cefr-cloze-header">
|
||||
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
|
||||
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
|
||||
${q.source_book_title ? `<span class="cefr-book-badge" title="${escapeHtml(q.source_chapter_title || '')}">📖 «${escapeHtml(q.source_book_title)}»${q.source_chapter_title ? ` (${escapeHtml(q.source_chapter_title)})` : ''}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="cefr-sentence-wrap">
|
||||
@@ -383,6 +538,7 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
${renderOptionsBreakdown(res.options_breakdown)}
|
||||
`;
|
||||
@@ -416,6 +572,7 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
|
||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
|
||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||
<div class="cefr-dual-ingested-box">
|
||||
<div class="dual-ingested-header">
|
||||
|
||||
Reference in New Issue
Block a user