3c45c606bf
Раньше «Добавить вопросы» в конструкторе тестов грузил лишь первые 100 вопросов предмета (дефолтный лимит API), а поиск фильтровал клиентски только эти 100 — для математики (1753 вопроса) 1653 были недоступны и не находились. Теперь пикер ходит на сервер (бэкенд уже умеет q/difficulty/type/page): поле поиска (debounce 300мс) ищет по ВСЕМУ банку предмета; кнопка «Показать ещё» подгружает страницами по 100 с индикатором «Показано N из total»; добавлены фильтры по сложности и типу. Поиск/фильтры сохраняются между перерисовками (после добавления вопроса). Чистый фронтенд (tests.js + CSS в admin.html); бэкенд не тронут. Verified: backend list q/difficulty/type/paging 8/8; headless-смоук пикера 12/12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
376 lines
17 KiB
JavaScript
376 lines
17 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 = await LS.getTest(id);
|
||
const inIds = new Set(t.questions.map(q => q.id));
|
||
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
|
||
const prev = _tstPickerCache[id] || {};
|
||
_tstPickerCache[id] = {
|
||
subject_slug: t.subject_slug, inIds,
|
||
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
|
||
rows: [], total: 0, page: 1, loading: false,
|
||
};
|
||
|
||
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-pick-filters">
|
||
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
|
||
<option value="">Любая сложность</option>
|
||
<option value="1">Лёгкий</option>
|
||
<option value="2">Средний</option>
|
||
<option value="3">Сложный</option>
|
||
</select>
|
||
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
|
||
<option value="">Любой тип</option>
|
||
<option value="single">Один</option>
|
||
<option value="multi">Несколько</option>
|
||
<option value="true_false">Верно/Нет</option>
|
||
<option value="short_answer">Краткий</option>
|
||
<option value="matching">Сопоставление</option>
|
||
</select>
|
||
</div>
|
||
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
|
||
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
|
||
</div>
|
||
</div>`;
|
||
// restore search/filters into controls
|
||
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
|
||
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
|
||
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
|
||
AdminCtx.renderMath(inner);
|
||
if (window.lucide) lucide.createIcons();
|
||
await pickerLoad(id, true);
|
||
} catch (e) {
|
||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
|
||
reset=true — новый поиск/фильтр (страница 1, заменяем); иначе — «показать ещё». */
|
||
async function pickerLoad(id, reset) {
|
||
const cache = _tstPickerCache[id];
|
||
if (!cache || cache.loading) return;
|
||
cache.loading = true;
|
||
if (reset) { cache.page = 1; cache.rows = []; }
|
||
const listEl = document.getElementById('tstpicker-' + id);
|
||
const footEl = document.getElementById('tstfoot-' + id);
|
||
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const p = new URLSearchParams();
|
||
p.set('subject', cache.subject_slug || '');
|
||
p.set('sort', 'date_desc');
|
||
p.set('page', cache.page);
|
||
p.set('limit', 100);
|
||
if (cache.q) p.set('q', cache.q);
|
||
if (cache.difficulty) p.set('difficulty', cache.difficulty);
|
||
if (cache.type) p.set('type', cache.type);
|
||
const data = await LS.get('/api/questions?' + p.toString());
|
||
const rows = Array.isArray(data) ? data : (data.rows || []);
|
||
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
|
||
cache.rows = reset ? rows : cache.rows.concat(rows);
|
||
cache.page += 1;
|
||
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
|
||
if (footEl) footEl.innerHTML = pickerFootHtml(id);
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch (e) {
|
||
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
|
||
} finally { cache.loading = false; }
|
||
}
|
||
|
||
function pickerFootHtml(id) {
|
||
const c = _tstPickerCache[id];
|
||
if (!c || !c.total) return '';
|
||
const more = c.rows.length < c.total
|
||
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
|
||
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
|
||
}
|
||
|
||
function pickerMore(id) { pickerLoad(id, false); }
|
||
|
||
function pickerFilterChange(id) {
|
||
const c = _tstPickerCache[id];
|
||
if (!c) return;
|
||
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
|
||
c.type = document.getElementById('tstft-' + id)?.value || '';
|
||
pickerLoad(id, true);
|
||
}
|
||
|
||
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) {
|
||
const c = _tstPickerCache[tid] || {};
|
||
const searching = c.q || c.difficulty || c.type;
|
||
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</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('');
|
||
}
|
||
|
||
const _pickDebounce = {};
|
||
function filterTstPicker(tid) {
|
||
const cache = _tstPickerCache[tid];
|
||
if (!cache) return;
|
||
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
|
||
clearTimeout(_pickDebounce[tid]);
|
||
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
|
||
}
|
||
|
||
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.pickerMore = pickerMore;
|
||
window.pickerFilterChange = pickerFilterChange;
|
||
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,
|
||
};
|
||
})();
|