Files
Learn_System/frontend/js/admin/sections/tests.js
T
Maxim Dolgolyov c6d323ec6d feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»
Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:42 +03:00

301 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* admin → tests section (тест-шаблоны: создание + редактирование + список вопросов) */
(function () {
'use strict';
let inited = false;
let allTests = [];
let openTstId = null;
let editingTstId = null;
let _tstShowAnswers = true;
const _tstPickerCache = {};
async function load() {
const subj = document.getElementById('tst-subj').value;
const wrap = document.getElementById('tst-list-wrap');
wrap.innerHTML = '<div class="spinner"></div>';
try {
allTests = await LS.getTests(subj || null);
renderTests();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTests() {
const { fmtDate } = AdminCtx;
const search = document.getElementById('tst-search').value.toLowerCase();
const filtered = search ? allTests.filter(t => t.title.toLowerCase().includes(search)) : allTests;
document.getElementById('tst-count').textContent = `${filtered.length} тестов`;
const wrap = document.getElementById('tst-list-wrap');
if (!filtered.length) { wrap.innerHTML = '<div class="empty">Тестов не найдено</div>'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
wrap.innerHTML = `<div class="q-list">${filtered.map(t => `
<div class="q-card" id="tstcard-${t.id}">
<div class="q-card-head">
<span class="q-card-num">#${t.id}</span>
<div class="q-card-body" onclick="toggleTstDrawer(${t.id})">
<div class="q-card-text">${esc(t.title)}</div>
<div class="q-card-meta">
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="tst-drawer" id="tstdrawer-${t.id}" style="display:none">
<div class="tst-drawer-inner" id="tstdinner-${t.id}">
<div class="spinner"></div>
</div>
</div>
</div>`).join('')}</div>`;
if (window.lucide) lucide.createIcons();
}
async function toggleTstDrawer(id) {
const drawer = document.getElementById('tstdrawer-' + id);
if (!drawer) return;
if (openTstId && openTstId !== id) {
const old = document.getElementById('tstdrawer-' + openTstId);
if (old) old.style.display = 'none';
}
if (openTstId === id) {
drawer.style.display = 'none'; openTstId = null; return;
}
openTstId = id;
drawer.style.display = '';
await renderTstDrawer(id);
}
async function renderTstDrawer(id) {
const inner = document.getElementById('tstdinner-' + id);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (${t.questions.length})</div>
<div class="tst-q-list" id="tstql-${id}">${renderTstQList(t.questions, id)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
</div>
</div>`;
AdminCtx.renderMath(inner);
if (window.lucide) lucide.createIcons();
} catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTstQList(questions, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
return questions.map((q, i) => `
<div class="tst-q-item" id="tstqitem-${tid}-${q.id}">
<span class="tst-q-num">${i+1}.</span>
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-rem" onclick="tstRemoveQ(${tid},${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
return questions.map(q => {
const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||''}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-add${added?' added':''}" id="tstbtn-${tid}-${q.id}"
title="${added?'Уже в тесте':'Добавить'}" ${added?'disabled':'onclick="tstAddQ('+tid+','+q.id+')"'}>${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+'}</button>
</div>`;
}).join('');
}
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
if (!cache) return;
const filtered = search
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
: cache.subjectQs;
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
}
async function tstAddQ(tid, qid) {
const btn = document.getElementById(`tstbtn-${tid}-${qid}`);
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
await LS.addQuestionsToTest(tid, [qid]);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count++;
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); if (btn) { btn.disabled=false; btn.textContent='+'; } }
}
async function tstRemoveQ(tid, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count = Math.max(0, t.question_count - 1);
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ── Test modal ── */
function setTstShowAnswers(val) {
_tstShowAnswers = val;
document.getElementById('tstf-show-yes').classList.toggle('active', val);
document.getElementById('tstf-show-no').classList.toggle('active', !val);
}
function openTstModal(t = null) {
editingTstId = t ? t.id : null;
document.getElementById('tst-modal-title').textContent = t ? `Редактировать: ${t.title}` : 'Создать тест';
document.getElementById('tstf-title').value = t?.title || '';
document.getElementById('tstf-subject').value = t?.subject_slug || '';
document.getElementById('tstf-desc').value = t?.description || '';
document.getElementById('tstf-time').value = t?.time_limit || '';
document.getElementById('tstf-error').textContent = '';
setTstShowAnswers(t ? (t.show_answers !== 0) : true);
document.getElementById('tst-modal').classList.add('open');
setTimeout(() => document.getElementById('tstf-title').focus(), 80);
}
function editTst(id) {
const t = allTests.find(x => x.id === id);
if (t) openTstModal(t);
}
function closeTstModal() {
document.getElementById('tst-modal').classList.remove('open');
editingTstId = null;
}
async function saveTst() {
const title = document.getElementById('tstf-title').value.trim();
const subject_slug= document.getElementById('tstf-subject').value;
const description = document.getElementById('tstf-desc').value.trim();
const errEl = document.getElementById('tstf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
const btn = document.getElementById('tstf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
const show_answers = _tstShowAnswers ? 1 : 0;
const timeVal = parseInt(document.getElementById('tstf-time').value, 10);
const time_limit = timeVal >= 1 ? Math.min(600, timeVal) : null;
try {
if (editingTstId) {
await LS.updateTest(editingTstId, { title, subject_slug, description: description||null, show_answers, time_limit });
const idx = allTests.findIndex(x => x.id === editingTstId);
if (idx !== -1) Object.assign(allTests[idx], { title, subject_slug, description, show_answers, time_limit });
} else {
const { id } = await LS.createTest({ title, subject_slug, description: description||null, show_answers, time_limit });
allTests.unshift({ id, title, subject_slug, description, question_count: 0, created_at: new Date().toISOString() });
closeTstModal();
renderTests();
openTstId = id;
document.getElementById('tstdrawer-' + id).style.display = '';
await renderTstDrawer(id);
return;
}
closeTstModal();
renderTests();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
async function deleteTst(id) {
const t = allTests.find(x => x.id === id);
if (!await LS.confirm(`Удалить тест «${t?.title}»?`, { title: 'Удалить тест', confirmText: 'Удалить' })) return;
try {
await LS.deleteTest(id);
allTests = allTests.filter(x => x.id !== id);
if (openTstId === id) openTstId = null;
renderTests();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
async function toggleTstAvail(id) {
const t = allTests.find(x => x.id === id);
if (!t) return;
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
const next = t.available_to_students ? 0 : 1;
try {
await LS.updateTest(id, { available_to_students: next });
t.available_to_students = next;
renderTests();
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers
window.loadTests = load;
window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker;
window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers;
window.openTstModal = openTstModal;
window.editTst = editTst;
window.closeTstModal = closeTstModal;
window.saveTst = saveTst;
window.deleteTst = deleteTst;
window.toggleTstAvail = toggleTstAvail;
window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();