c5d440a7a9
Рассогласование: админ-настройка допускала режимы 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>
340 lines
17 KiB
JavaScript
340 lines
17 KiB
JavaScript
'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,
|
||
};
|
||
})();
|