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>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 22:17:17 +03:00
parent 1649d6c2ec
commit 3c45c606bf
2 changed files with 102 additions and 20 deletions
+7
View File
@@ -486,6 +486,13 @@
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
.tst-search:focus { border-color: var(--violet); }
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
.tst-pick-sel:focus { border-color: var(--violet); }
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* formula bar */
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
+95 -20
View File
@@ -79,16 +79,15 @@
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 t = await LS.getTest(id);
const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
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">
@@ -98,17 +97,89 @@
</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>
<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>';
@@ -129,7 +200,11 @@
function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
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}">
@@ -147,15 +222,13 @@
}).join('');
}
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
const _pickDebounce = {};
function filterTstPicker(tid) {
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(); }
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
clearTimeout(_pickDebounce[tid]);
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
}
async function tstAddQ(tid, qid) {
@@ -282,6 +355,8 @@
window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker;
window.pickerMore = pickerMore;
window.pickerFilterChange = pickerFilterChange;
window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers;