Files
Maxim Dolgolyov 7c32501e18 fix(admin): отображать HTML-разметку вопросов в секции «Вопросы» при allow_html
Секция игнорировала флаг allow_html и всегда экранировала текст/опции/
пояснение, из-за чего <div class=task-figure><img>, <b> и пр. показывались
как сырой текст. Теперь — как в test-run.html: allow_html ? raw : esc.
Также добавлен q.allow_html в SELECT списка вопросов (его не было в ответе API).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:29:00 +03:00

537 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 → questions section (the biggest — список + Q-modal + CSV) */
(function () {
'use strict';
let inited = false;
let allQuestions = [];
let editingQId = null;
let openQId = null;
let _topicMap = {};
let _currentType = 'single';
// window._matchPairs is exposed on window because HTML oninput uses bare `_matchPairs[i].left=this.value`
window._matchPairs = window._matchPairs || [];
let _opts = [];
let _focusedInput = null;
let _prevTimer = null;
const OPT_LETTERS = 'АБВГДЕ';
function updateCharCounter(el, cntId, max) {
const n = el.value.length;
const cnt = document.getElementById(cntId);
if (!cnt) return;
cnt.textContent = `${n} / ${max}`;
cnt.className = 'char-counter' + (n > max * 0.9 ? ' warn' : '') + (n >= max ? ' over' : '');
}
async function onQSubjectChange() {
const slug = document.getElementById('q-subject').value;
const sel = document.getElementById('q-topic');
sel.innerHTML = '<option value="">Все темы</option>';
if (slug) {
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => sel.appendChild(new Option(t.name, t.id)));
} catch {}
}
load();
}
async function load() {
const subject = document.getElementById('q-subject').value;
const topic_id = document.getElementById('q-topic').value;
const sort = document.getElementById('q-sort').value;
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML = LS.skeleton(5);
try {
allQuestions = await LS.getQuestions(subject || null, topic_id || null, sort);
renderQuestions();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка загрузки: ${esc(e.message)}</div>`;
}
}
function renderQuestions() {
const { DIFFS } = AdminCtx;
const search = document.getElementById('q-search').value.toLowerCase();
const filtered = search
? allQuestions.filter(q => q.text.toLowerCase().includes(search) || (q.topic||'').toLowerCase().includes(search))
: allQuestions;
document.getElementById('q-count').textContent = `${filtered.length} вопросов`;
if (!filtered.length) {
document.getElementById('q-list-wrap').innerHTML = '<div class="empty">Вопросов не найдено</div>';
return;
}
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML =
`<div class="q-list">${filtered.map(q => {
const diffCls = `diff-${q.difficulty}`;
const optsHtml = (q.options || []).map(o =>
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${q.allow_html ? o.text : esc(o.text)}
</div>`).join('');
const explHtml = q.explanation
? `<div class="q-expl"><strong>Пояснение:</strong> ${q.allow_html ? q.explanation : esc(q.explanation)}</div>` : '';
return `<div class="q-card" id="qcard-${q.id}">
<div class="q-card-head">
<span class="q-card-num">#${q.id}</span>
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
<div class="q-card-text">${q.allow_html ? q.text : esc(q.text)}</div>
<div class="q-card-meta">
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
<span class="q-badge ${diffCls}">${DIFFS[q.difficulty]||q.difficulty}</span>
<span style="font-size:0.72rem;color:var(--text-3);background:rgba(15,23,42,0.05);padding:2px 7px;border-radius:999px">${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${q.options?.length||0} вар.</span>
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="editQ(${q.id})">Изменить</button>
<button class="btn-dup-q" onclick="dupQ(${q.id})" title="Дублировать">⧉</button>
<button class="btn-del-q" onclick="deleteQ(${q.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="q-card-detail" id="qdetail-${q.id}">
${optsHtml}${explHtml}
</div>
</div>`;
}).join('')}</div>`;
AdminCtx.renderMath(wrap);
if (window.lucide) lucide.createIcons();
}
function toggleQDetail(id) {
if (openQId === id) {
document.getElementById('qdetail-' + id)?.classList.remove('open');
openQId = null; return;
}
if (openQId) document.getElementById('qdetail-' + openQId)?.classList.remove('open');
document.getElementById('qdetail-' + id)?.classList.add('open');
openQId = id;
}
async function dupQ(id) {
try {
const { id: newId } = await LS.duplicateQuestion(id);
await load();
setTimeout(() => document.getElementById('qcard-' + newId)?.scrollIntoView({ behavior:'smooth', block:'center' }), 300);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function deleteQ(id) {
if (!await LS.confirm(`Удалить вопрос #${id}?`, { title: 'Удалить вопрос', confirmText: 'Удалить' })) return;
try {
await LS.deleteQuestion(id);
allQuestions = allQuestions.filter(q => q.id !== id);
renderQuestions();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Question type ─── */
function setQType(type) {
_currentType = type;
document.querySelectorAll('[data-type]').forEach(b => b.classList.toggle('active', b.dataset.type === type));
const isMatching = type === 'matching';
const isShort = type === 'short_answer';
const showOpts = !isShort && !isMatching;
const optsHeader = document.getElementById('qf-opts-header');
if (optsHeader) optsHeader.style.display = showOpts ? '' : 'none';
document.getElementById('qf-opts').style.display = showOpts ? '' : 'none';
document.getElementById('qf-short-wrap').style.display = isShort ? '' : 'none';
document.getElementById('qf-match-wrap').style.display = isMatching ? '' : 'none';
document.getElementById('btn-add-opt').style.display = showOpts && type !== 'true_false' ? '' : 'none';
if (type === 'true_false') {
initOpts([{ text:'Верно', is_correct:false }, { text:'Неверно', is_correct:false }]);
} else if (isShort) {
_opts = [];
} else if (isMatching) {
_opts = [];
if (window._matchPairs.length === 0) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
renderMatchRows();
} else {
if (_opts.length === 0 || _opts[0]?.text === 'Верно') initOpts([{},{},{},{}]);
else renderOptRows(_opts);
}
}
function renderMatchRows() {
const cont = document.getElementById('qf-match-rows');
cont.innerHTML = window._matchPairs.map((p, i) => `
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px" data-mi="${i}">
<input type="text" class="form-ctrl" placeholder="Элемент…" value="${esc(p.left)}"
oninput="window._matchPairs[${i}].left=this.value" style="margin:0" />
<input type="text" class="form-ctrl" placeholder="Пара к нему…" value="${esc(p.right)}"
oninput="window._matchPairs[${i}].right=this.value" style="margin:0" />
<button type="button" onclick="removeMatchPair(${i})" style="border:none;background:none;color:var(--text-3);cursor:pointer;padding:0 6px;display:flex;align-items:center" title="Удалить"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function addMatchPair() {
window._matchPairs.push({left:'',right:''});
renderMatchRows();
}
function removeMatchPair(i) {
window._matchPairs.splice(i, 1);
renderMatchRows();
}
/* ─── Formula bar ─── */
document.addEventListener('focusin', e => {
if (e.target.closest && e.target.closest('#q-modal') &&
(e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type === 'text'))) {
_focusedInput = e.target;
}
});
function ins(latex) {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart ?? el.value.length;
const e2= el.selectionEnd ?? el.value.length;
const before = el.value.slice(0, s), after = el.value.slice(e2);
const opens = (before.match(/\\\(/g)||[]).length;
const closes = (before.match(/\\\)/g)||[]).length;
const insert = opens > closes ? latex : `\\(${latex}\\)`;
el.value = before + insert + after;
el.setSelectionRange(s + insert.length, s + insert.length);
el.focus();
updateQPreview();
}
function wrapMath() {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart, e2 = el.selectionEnd;
const sel = el.value.slice(s, e2) || 'x';
el.value = el.value.slice(0, s) + `\\(${sel}\\)` + el.value.slice(e2);
el.focus();
updateQPreview();
}
/* ─── Live preview ─── */
function updateQPreview() {
clearTimeout(_prevTimer);
_prevTimer = setTimeout(() => {
const text = (document.getElementById('qf-text').value || '').trim();
const el = document.getElementById('q-preview-text');
const wrap = document.getElementById('q-preview-wrap');
wrap.classList.toggle('hidden', !text);
if (!text) return;
el.textContent = text;
AdminCtx.renderMath(el);
}, 150);
}
// Formula bar toggle (default collapsed)
window.toggleFormulaBar = function () {
const bar = document.getElementById('formula-bar');
const btn = document.getElementById('qf-fml-toggle');
const open = bar.classList.toggle('visible');
btn.classList.toggle('open', open);
};
// Wire textarea input to preview
setTimeout(() => {
const ta = document.getElementById('qf-text');
if (ta) ta.addEventListener('input', updateQPreview);
}, 0);
/* ─── Dynamic options ─── */
function renderOptRows(opts) {
const grid = document.getElementById('qf-opts');
const isMulti = _currentType === 'multi';
grid.innerHTML = opts.map((o, i) => `
<div class="opt-row${o.is_correct ? ' opt-correct' : ''}" data-i="${i}">
<span class="opt-letter">${OPT_LETTERS[i]}</span>
${isMulti
? `<input type="checkbox" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onCheckChange(${i}, this.checked)" />`
: `<input type="radio" name="qf-correct" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onRadioChange(${i})" />`}
<input type="text" class="opt-input" placeholder="Вариант ${OPT_LETTERS[i]}"
value="${esc(o.text||'')}" oninput="syncOptText(${i}, this.value)" />
${opts.length > 2
? `<button type="button" class="btn-rem-opt" onclick="removeOpt(${i})" title="Удалить"></button>`
: '<span style="width:24px;flex-shrink:0"></span>'}
</div>`).join('');
document.getElementById('btn-add-opt').style.display = opts.length >= 6 ? 'none' : '';
}
function onCheckChange(idx, checked) {
_opts[idx].is_correct = checked;
document.querySelector(`#qf-opts .opt-row[data-i="${idx}"]`)?.classList.toggle('opt-correct', checked);
}
function initOpts(opts) {
_opts = opts.length ? opts.map(o => ({ text: o.text||'', is_correct: !!o.is_correct }))
: [{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false}];
renderOptRows(_opts);
}
function onRadioChange(idx) {
_opts.forEach((o, i) => o.is_correct = (i === idx));
renderOptRows(_opts);
}
function syncOptText(idx, val) { _opts[idx].text = val; }
function addOpt() {
if (_opts.length >= 6) return;
_opts.push({ text: '', is_correct: false });
renderOptRows(_opts);
const rows = document.querySelectorAll('#qf-opts .opt-row');
rows[rows.length - 1]?.querySelector('input[type=text]')?.focus();
}
function removeOpt(idx) {
if (_opts.length <= 2) return;
const wasCorrect = _opts[idx].is_correct;
_opts.splice(idx, 1);
if (wasCorrect && _opts.length > 0) _opts[0].is_correct = true;
renderOptRows(_opts);
}
/* ─── Modal ─── */
function openQModal(q = null) {
editingQId = q ? q.id : null;
document.getElementById('q-modal-title').textContent = q ? `Редактировать вопрос #${q.id}` : 'Добавить вопрос';
const textEl = document.getElementById('qf-text');
textEl.value = q?.text || '';
updateCharCounter(textEl, 'qf-text-cnt', 500);
document.getElementById('qf-explanation').value = q?.explanation || '';
document.getElementById('qf-difficulty').value = q?.difficulty ?? 2;
document.getElementById('qf-subject').value = q?.subject_slug || '';
document.getElementById('qf-topic-text').value = q?.topic || '';
document.getElementById('qf-correct-text').value = q?.correct_text || '';
document.getElementById('qf-error').textContent = '';
const imgVal = q?.image || '';
document.getElementById('qf-image').value = imgVal;
updateImagePreview(imgVal);
if (q?.type === 'matching') {
window._matchPairs = (q.options || []).map(o => ({ left: o.text, right: o.match_pair || '' }));
if (!window._matchPairs.length) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
} else {
window._matchPairs = [];
}
setQType(q?.type || 'single');
if (q?.type !== 'matching') initOpts(q?.options || []);
updateQPreview();
loadQModalTopics();
document.getElementById('q-modal').classList.add('open');
setTimeout(() => textEl.focus(), 80);
}
function editQ(id) {
const q = allQuestions.find(x => x.id === id);
if (q) openQModal(q);
}
function closeQModal() {
document.getElementById('q-modal').classList.remove('open');
editingQId = null;
}
async function loadQModalTopics() {
const slug = document.getElementById('qf-subject').value;
const dl = document.getElementById('qf-topic-list');
dl.innerHTML = '';
_topicMap = {};
if (!slug) return;
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => {
dl.appendChild(new Option(t.name));
_topicMap[t.name.toLowerCase()] = t.id;
});
} catch {}
}
async function saveQuestion() {
const text = document.getElementById('qf-text').value.trim();
const explanation = document.getElementById('qf-explanation').value.trim();
const difficulty = Number(document.getElementById('qf-difficulty').value);
const subject_slug = document.getElementById('qf-subject').value;
const topicText = document.getElementById('qf-topic-text').value.trim();
const type = _currentType;
const errEl = document.getElementById('qf-error');
errEl.textContent = '';
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!text) { errEl.textContent = 'Введите текст вопроса'; return; }
let options = null;
let correct_text = null;
if (type === 'short_answer') {
correct_text = document.getElementById('qf-correct-text').value.trim();
if (!correct_text) { errEl.textContent = 'Введите правильный ответ'; return; }
} else if (type === 'matching') {
document.querySelectorAll('#qf-match-rows [data-mi]').forEach((row, i) => {
const [l, r] = row.querySelectorAll('input');
if (window._matchPairs[i]) { window._matchPairs[i].left = l.value.trim(); window._matchPairs[i].right = r.value.trim(); }
});
if (window._matchPairs.length < 2) { errEl.textContent = 'Нужно минимум 2 пары'; return; }
if (window._matchPairs.some(p => !p.left || !p.right)) { errEl.textContent = 'Заполните все пары'; return; }
options = window._matchPairs.map(p => ({ text: p.left, match_pair: p.right, is_correct: 0 }));
} else {
document.querySelectorAll('#qf-opts .opt-row').forEach((row, i) => {
if (_opts[i]) _opts[i].text = row.querySelector('input[type=text]').value.trim();
});
options = _opts.map(o => ({ text: o.text, is_correct: o.is_correct }));
if (options.length < 2) { errEl.textContent = 'Нужно минимум 2 варианта ответа'; return; }
if (options.some(o => !o.text)) { errEl.textContent = 'Заполните все варианты ответов'; return; }
if (!options.some(o => o.is_correct)) { errEl.textContent = 'Отметьте правильный ответ'; return; }
}
const knownId = _topicMap[topicText.toLowerCase()];
const topic_id = knownId || null;
const topic_name = !knownId && topicText ? topicText : null;
const image = document.getElementById('qf-image').value.trim() || null;
const btn = document.getElementById('qf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
if (editingQId) {
await LS.updateQuestion(editingQId, { text, type, correct_text, difficulty, explanation: explanation||null, topic_id, topic_name, options, image });
} else {
await LS.createQuestion({ subject_slug, topic_id, topic_name, text, type, correct_text, difficulty, explanation: explanation||null, options, image });
}
closeQModal();
load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ── Image upload & preview ── */
function updateImagePreview(url) {
const wrap = document.getElementById('qf-image-preview');
const img = document.getElementById('qf-image-img');
if (url) { img.src = url; wrap.classList.add('visible'); }
else { wrap.classList.remove('visible'); img.src = ''; }
}
function clearQuestionImage() {
document.getElementById('qf-image').value = '';
updateImagePreview('');
}
async function handleImageFileSelect(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const btn = document.getElementById('btn-img-upload');
const lbl = document.getElementById('btn-img-upload-lbl');
btn.disabled = true;
lbl.textContent = 'Загрузка…';
try {
const fd = new FormData();
fd.append('file', file);
fd.append('title', 'Question image: ' + file.name);
fd.append('is_public', '1');
const res = await fetch('/api/files', {
method: 'POST',
headers: { Authorization: 'Bearer ' + localStorage.getItem('ls_token') },
body: fd
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const { id } = await res.json();
const url = `/api/files/${id}/download`;
document.getElementById('qf-image').value = url;
updateImagePreview(url);
} catch (e) {
document.getElementById('qf-error').textContent = 'Ошибка загрузки: ' + e.message;
} finally {
btn.disabled = false;
lbl.textContent = 'Загрузить';
}
}
document.addEventListener('DOMContentLoaded', () => {
const imgInput = document.getElementById('qf-image');
if (imgInput) imgInput.addEventListener('input', e => updateImagePreview(e.target.value.trim()));
});
/* ── CSV Import ── */
async function importCSVFile(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const fd = new FormData();
fd.append('file', file);
const btn = document.querySelector('[onclick="document.getElementById(\'csv-file-input\').click()"]');
if (btn) { btn.disabled = true; btn.textContent = 'Импорт…'; }
try {
const { imported, errors } = await LS.importQuestions(fd);
LS.toast(`Импортировано: ${imported} вопросов${errors.length ? ` (${errors.length} ошибок)` : ''}`, imported > 0 ? 'success' : 'warn', 5000);
load();
} catch (e) {
LS.toast('Ошибка импорта: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i data-lucide="upload" style="width:14px;height:14px;vertical-align:-2px"></i> Импорт CSV'; if(window.lucide)lucide.createIcons(); }
}
}
function downloadCSVTemplate(e) {
e.preventDefault();
const header = 'subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year';
const example = [
'bio;Клетки;Что является «электростанцией» клетки?;2;single;Митохондрия;1;Рибосома;0;Лизосома;0;Ядро;0;;Митохондрии синтезируют АТФ;2024',
'bio;Клетки;Какие органоиды участвуют в синтезе белка?;2;multi;Рибосома;1;Митохондрия;0;Эндоплазматическая сеть;1;Лизосома;0;;',
'chem;Кислоты;Формула серной кислоты;1;short_answer;;;;;;;;H2SO4;;',
].join('\n');
const blob = new Blob(['' + header + '\n' + example], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'questions_template.csv';
a.click();
}
// Expose handlers used by onclick (HTML or sibling sections)
window.onQSubjectChange = onQSubjectChange;
window.loadQuestions = load;
window.renderQuestions = renderQuestions;
window.toggleQDetail = toggleQDetail;
window.dupQ = dupQ;
window.deleteQ = deleteQ;
window.setQType = setQType;
window.addMatchPair = addMatchPair;
window.removeMatchPair = removeMatchPair;
window.ins = ins;
window.wrapMath = wrapMath;
window.updateQPreview = updateQPreview;
window.onCheckChange = onCheckChange;
window.onRadioChange = onRadioChange;
window.syncOptText = syncOptText;
window.addOpt = addOpt;
window.removeOpt = removeOpt;
window.openQModal = openQModal;
window.editQ = editQ;
window.closeQModal = closeQModal;
window.loadQModalTopics = loadQModalTopics;
window.saveQuestion = saveQuestion;
window.updateImagePreview = updateImagePreview;
window.clearQuestionImage = clearQuestionImage;
window.handleImageFileSelect = handleImageFileSelect;
window.importCSVFile = importCSVFile;
window.downloadCSVTemplate = downloadCSVTemplate;
window.updateCharCounter = updateCharCounter;
window.AdminSections = window.AdminSections || {};
window.AdminSections.questions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
openModal: openQModal,
loadModalTopics: loadQModalTopics,
};
})();