c6d323ec6d
Раньше ученик видел лишь 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>
301 lines
13 KiB
JavaScript
301 lines
13 KiB
JavaScript
'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,
|
||
};
|
||
})();
|