Files
Maxim Dolgolyov c5d440a7a9 fix(tests): режимы доступных тестов только exam/practice + скрытие пустых предметов
Рассогласование: админ-настройка допускала режимы topic/random, но POST /api/sessions
принимает только exam/practice → клик по такому предмету падал с 400. Убрал topic/random
из валидатора subjects.js и из админ-дропдауна (SC_MODES). Дашборд: старые значения
topic/random коэрсятся в practice; предметы без вопросов в банке И без фикс-теста больше
не показываются (раньше давали 404 «No questions found» при запуске).

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

340 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 → subjects (доступные тесты) section */
(function () {
'use strict';
let inited = false;
// Старт сессии поддерживает только exam/practice (topic/random убраны — давали 400 на дашборде).
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
// кэш тестов по предмету для селектора
const _scTests = {};
async function loadScTests(slug) {
if (_scTests[slug]) return _scTests[slug];
const tests = await LS.getTests(slug);
_scTests[slug] = tests;
return tests;
}
function setSrcMode(slug, src) {
const rndBtn = document.getElementById(`sc-src-rnd-${slug}`);
const fixBtn = document.getElementById(`sc-src-fix-${slug}`);
const pick = document.getElementById(`sc-test-pick-${slug}`);
const cntWrap = document.getElementById(`sc-count-wrap-${slug}`);
rndBtn.classList.toggle('active', src === 'random');
fixBtn.classList.toggle('active', src === 'fixed');
pick.classList.toggle('open', src === 'fixed');
cntWrap.style.display = src === 'random' ? '' : 'none';
if (src === 'fixed') {
loadAndRenderTestPick(slug);
} else {
const dr = document.getElementById(`sc-qdr-${slug}`);
if (dr) { dr.style.display = 'none'; }
}
}
async function loadAndRenderTestPick(slug) {
const sel = document.getElementById(`sc-test-sel-${slug}`);
if (sel.dataset.loaded) return;
sel.innerHTML = '<option value="">Загрузка…</option>';
try {
const tests = await loadScTests(slug);
const cur = document.getElementById(`sc-card-${slug}`)?.dataset.testId || '';
sel.innerHTML = `<option value="">— случайные вопросы —</option>` +
tests.map(t => `<option value="${t.id}"${String(t.id) === cur ? ' selected' : ''}>${esc(t.title)} (${t.question_count ?? '?'} вопр.)</option>`).join('');
sel.dataset.loaded = '1';
} catch(e) {
sel.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
async function load() {
const wrap = document.getElementById('subj-config-list');
wrap.innerHTML = LS.skeleton(4);
try {
const subjects = await LS.getSubjects();
wrap.innerHTML = subjects.map(s => {
const hasFix = !!s.default_test_id;
const color = SC_COLORS[s.slug] || '#9B5DE5';
const mode = s.default_mode || 'exam';
const count = s.default_count || 25;
const srcLabel = hasFix ? 'Фикс. тест' : `${count} вопросов`;
return `
<div class="sc-card" id="sc-card-${s.slug}" data-test-id="${s.default_test_id || ''}">
<div class="sc-row-top" onclick="toggleScCard('${s.slug}')">
<div class="sc-icon" style="background:${color}"><i data-lucide="${SC_ICONS[s.slug]||'book'}"></i></div>
<div class="sc-info">
<div class="sc-name">${esc(s.name)}</div>
<div class="sc-summary" id="sc-sum-${s.slug}">
<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span>
<span class="sc-tag">${srcLabel}</span>
<span class="sc-qcount">${s.question_count ?? 0} в базе</span>
</div>
</div>
<i data-lucide="chevron-down" class="sc-chevron"></i>
</div>
<div class="sc-body">
<!-- Quick presets -->
<div class="sc-presets">
<button class="sc-preset${mode==='exam'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',25)">Экзамен 25</button>
<button class="sc-preset${mode==='exam'&&count===40&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',40)">Экзамен 40</button>
<button class="sc-preset${mode==='practice'&&count===15&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',15)">Практика 15</button>
<button class="sc-preset${mode==='practice'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',25)">Практика 25</button>
</div>
<!-- Detailed fields -->
<div class="sc-fields">
<div class="sc-field">
<span class="sc-label">Режим</span>
<select class="sc-select" id="sc-mode-${s.slug}">
${Object.entries(SC_MODES).map(([v, l]) =>
`<option value="${v}"${mode === v ? ' selected' : ''}>${l}</option>`
).join('')}
</select>
</div>
<div class="sc-field">
<span class="sc-label">Источник</span>
<div class="sc-src-toggle">
<button class="sc-src-btn${hasFix ? '' : ' active'}" id="sc-src-rnd-${s.slug}" onclick="setSrcMode('${s.slug}','random')">Случайные</button>
<button class="sc-src-btn${hasFix ? ' active' : ''}" id="sc-src-fix-${s.slug}" onclick="setSrcMode('${s.slug}','fixed')">Из теста</button>
</div>
</div>
<div class="sc-field" id="sc-count-wrap-${s.slug}" style="${hasFix ? 'display:none' : ''}">
<span class="sc-label">Вопросов</span>
<input class="sc-input" type="number" id="sc-count-${s.slug}" min="5" max="100" value="${count}" />
</div>
<div class="sc-test-pick${hasFix ? ' open' : ''}" id="sc-test-pick-${s.slug}">
<div class="sc-field">
<span class="sc-label">Тест</span>
<select class="sc-select" id="sc-test-sel-${s.slug}" onchange="onScTestChange('${s.slug}')">
<option value="${s.default_test_id || ''}" selected>Загрузка...</option>
</select>
</div>
<button class="sc-save-add" id="sc-qdr-btn-${s.slug}" style="display:${hasFix?'':'none'};align-self:flex-start"
onclick="toggleScDrawer('${s.slug}')"><i data-lucide="list" style="width:13px;height:13px;vertical-align:-2px"></i> Вопросы</button>
</div>
</div>
<!-- Footer -->
<div class="sc-footer">
<button class="sc-save" id="sc-save-btn-${s.slug}" onclick="saveSubjectConfig('${s.slug}')">Сохранить</button>
<button class="sc-save-add" onclick="goAddQuestion('${s.slug}')"><i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Вопрос</button>
</div>
</div>
</div>
<div id="sc-qdr-${s.slug}" style="display:none;border-top:1px solid var(--border);padding:20px 24px;background:rgba(238,242,255,0.5)">
<div id="sc-qdr-inner-${s.slug}"></div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
subjects.filter(s => s.default_test_id).forEach(s => {
loadAndRenderTestPick(s.slug);
const btn = document.getElementById(`sc-qdr-btn-${s.slug}`);
if (btn) btn.style.display = '';
});
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function toggleScCard(slug) {
const card = document.getElementById('sc-card-' + slug);
if (!card) return;
const wasOpen = card.classList.contains('open');
document.querySelectorAll('.sc-card.open').forEach(c => c.classList.remove('open'));
if (!wasOpen) {
card.classList.add('open');
if (window.lucide) lucide.createIcons({ nodes: [card] });
}
}
function applyPreset(slug, mode, count) {
document.getElementById('sc-mode-' + slug).value = mode;
document.getElementById('sc-count-' + slug).value = count;
setSrcMode(slug, 'random');
const card = document.getElementById('sc-card-' + slug);
card.querySelectorAll('.sc-preset').forEach(p => p.classList.remove('active'));
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
card.querySelectorAll('.sc-preset').forEach(p => {
const txt = p.textContent.trim();
const mLabel = SC_MODES[mode];
if (txt === mLabel + ' ' + count && !isFix) p.classList.add('active');
});
saveSubjectConfig(slug);
}
function updateScSummary(slug) {
const el = document.getElementById('sc-sum-' + slug);
if (!el) return;
const mode = document.getElementById('sc-mode-' + slug).value;
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
const count = document.getElementById('sc-count-' + slug).value;
const srcLabel = isFix ? 'Фикс. тест' : count + ' вопросов';
el.innerHTML = `<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span><span class="sc-tag">${srcLabel}</span>`;
}
async function saveSubjectConfig(slug) {
const btn = document.getElementById(`sc-save-btn-${slug}`);
const mode = document.getElementById(`sc-mode-${slug}`).value;
const isFix = document.getElementById(`sc-src-fix-${slug}`).classList.contains('active');
const count = Number(document.getElementById(`sc-count-${slug}`)?.value || 25);
const testId = isFix ? (document.getElementById(`sc-test-sel-${slug}`).value || null) : null;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
const payload = { default_mode: mode, default_count: count, default_test_id: testId ? Number(testId) : null };
try {
await LS.updateSubject(slug, payload);
document.getElementById(`sc-card-${slug}`).dataset.testId = testId || '';
if (isFix) document.getElementById(`sc-test-sel-${slug}`).dataset.loaded = '';
updateScSummary(slug);
if (btn) { btn.classList.add('saved'); btn.textContent = 'Сохранено'; }
setTimeout(() => { if (btn) { btn.classList.remove('saved'); btn.textContent = 'Сохранить'; btn.disabled = false; } }, 1500);
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
}
}
function onScTestChange(slug) {
const tid = document.getElementById(`sc-test-sel-${slug}`).value;
const btn = document.getElementById(`sc-qdr-btn-${slug}`);
btn.style.display = tid ? '' : 'none';
const dr = document.getElementById(`sc-qdr-${slug}`);
dr.style.display = 'none';
document.getElementById(`sc-qdr-inner-${slug}`).innerHTML = '';
}
async function toggleScDrawer(slug) {
const dr = document.getElementById(`sc-qdr-${slug}`);
const tid = Number(document.getElementById(`sc-test-sel-${slug}`).value);
if (!tid) return;
if (dr.style.display !== 'none') { dr.style.display = 'none'; return; }
dr.style.display = '';
await renderScDrawer(slug, tid);
}
const _scCache = {}; // tid → { test, subjectQs }
async function renderScDrawer(slug, tid) {
const inner = document.getElementById(`sc-qdr-inner-${slug}`);
inner.innerHTML = LS.skeleton(3, 'row');
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(tid),
LS.getQuestions(slug, null, 'date_asc').catch(() => []),
]);
_scCache[tid] = { test: t, subjectQs };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (<span id="sc-qcnt-${tid}">${t.questions.length}</span>)</div>
<div class="tst-q-list" id="sc-ql-${tid}">${renderScQList(t.questions, tid, slug)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить из базы</div>
<input class="tst-search" placeholder="Поиск…" oninput="filterScPicker(${tid},'${slug}',this.value)" />
<div class="tst-q-list" id="sc-pick-${tid}">${renderScPicker(subjectQs, new Set(t.questions.map(q=>q.id)), tid, slug)}</div>
</div>
</div>`;
AdminCtx.renderMath(inner);
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderScQList(questions, tid, slug) {
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="sc-qi-${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="scRemoveQ(${tid},'${slug}',${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderScPicker(questions, inIds, tid, slug) {
const { DIFF_LABELS, qTypeBadge } = 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="sc-pick-item-${tid}-${q.id}" style="${added?'opacity:0.4;pointer-events:none':''}">
<div class="tst-q-body" style="flex:1">
<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)}
${q.topic ? `<span class="tst-q-badge" style="background:rgba(6,214,224,0.1);color:#05aab3">${esc(q.topic)}</span>` : ''}
</div>
</div>
<button class="btn-tst-add" id="sc-add-btn-${tid}-${q.id}" onclick="scAddQ(${tid},'${slug}',${q.id},this)" title="Добавить">${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+' }</button>
</div>`;
}).join('');
}
function filterScPicker(tid, slug, q) {
const cache = _scCache[tid];
if (!cache) return;
const lq = q.toLowerCase();
const filtered = lq.length < 1
? cache.subjectQs
: cache.subjectQs.filter(x => x.text.toLowerCase().includes(lq) || (x.topic||'').toLowerCase().includes(lq));
const inIds = new Set(cache.test.questions.map(x=>x.id));
document.getElementById(`sc-pick-${tid}`).innerHTML = renderScPicker(filtered, inIds, tid, slug);
}
async function scAddQ(tid, slug, qid, btn) {
btn.disabled = true; btn.textContent = '…';
try {
await LS.addQuestionsToTest(tid, [qid]);
const t = await LS.getTest(tid);
_scCache[tid].test = t;
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity='0.4'; item.style.pointerEvents='none'; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i>'; if(window.lucide)lucide.createIcons(); }
AdminCtx.renderMath(document.getElementById(`sc-ql-${tid}`));
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled=false; btn.textContent='+'; }
}
async function scRemoveQ(tid, slug, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = await LS.getTest(tid);
_scCache[tid].test = t;
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity=''; item.style.pointerEvents=''; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.textContent='+'; addBtn.disabled=false; }
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose onclick handlers
window.toggleScCard = toggleScCard;
window.applyPreset = applyPreset;
window.setSrcMode = setSrcMode;
window.saveSubjectConfig = saveSubjectConfig;
window.onScTestChange = onScTestChange;
window.toggleScDrawer = toggleScDrawer;
window.filterScPicker = filterScPicker;
window.scAddQ = scAddQ;
window.scRemoveQ = scRemoveQ;
window.AdminSections = window.AdminSections || {};
window.AdminSections.subjects = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();