Files
Maxim Dolgolyov 3c45c606bf feat(admin/tests): пикер вопросов — серверный поиск по всему банку + «Показать ещё» + фильтры
Раньше «Добавить вопросы» в конструкторе тестов грузил лишь первые 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>
2026-06-23 22:17:17 +03:00

376 lines
17 KiB
JavaScript
Raw Permalink 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 = 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,
};
})();