From 31a51956b6824a244a13e860c1311f214f0feaca Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 16 May 2026 13:13:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20exam9=20=E2=80=94=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B0=D0=BD=D1=82=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D0=94?= =?UTF-8?q?=D0=97=20+=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=87=D1=91=D1=82=D0=BD=D1=8B=D1=85=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=BD=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов: - 400 questions с allow_html=1, source_type='экзамен 9', year=2025 - 540 options (single-choice) + correct_text (short_answer) - 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N" - exam9_variant_tests маппинг для назначения Назначение варианта как ДЗ на /exam9 (для учителей/админов): - Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть) - Модалка выбора классов + опциональный deadline - POST /api/assignments/bulk с test_id из exam9_variant_tests Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html: - Миграция 003: ALTER TABLE questions ADD COLUMN allow_html - sessionController: SELECT возвращают allow_html и image - test-run.html: рендер q.text и opt.text как HTML при allow_html=1 - test-result.html: то же для explanation и opt.text - KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт class_ids (array). Существующий вызов из classes.html был сломан; исправлено вместе. Команда: node backend/scripts/import-exam9.js (--all для всех 80) --- backend/scripts/import-exam9.js | 186 ++++++++++++++++++ backend/src/controllers/sessionController.js | 4 +- .../src/db/migrations/003_exam9_questions.sql | 8 + backend/src/routes/assignments.js | 8 +- backend/src/routes/exam9.js | 18 ++ backend/src/server.js | 2 + frontend/exam9.html | 97 +++++++++ frontend/js/exam9/app.js | 134 ++++++++++++- frontend/test-result.html | 11 +- frontend/test-run.html | 6 +- 10 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 backend/scripts/import-exam9.js create mode 100644 backend/src/db/migrations/003_exam9_questions.sql create mode 100644 backend/src/routes/exam9.js diff --git a/backend/scripts/import-exam9.js b/backend/scripts/import-exam9.js new file mode 100644 index 0000000..72271a7 --- /dev/null +++ b/backend/scripts/import-exam9.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * import-exam9.js — imports ODD variants from /frontend/js/exam9/variants/ into question bank. + * + * Usage: + * node backend/scripts/import-exam9.js # imports v01, v03, ..., v79 + * node backend/scripts/import-exam9.js --all # imports ALL 80 variants + * + * Idempotent: drops existing exam9 tests/questions before re-import (uses exam9_variant_tests map). + * + * For each variant V it creates: + * - 1 row in tests (title="Экзамен 9 — Вариант V", subject_slug='math', show_answers=1) + * - 1 row in exam9_variant_tests (V → test.id) + * - 10 rows in questions (one per task; allow_html=1, explanation=sol) + * - For tasks with opts: rows in options (5 per task, 1 marked correct) + * - For tasks without opts: questions.type='short_answer', correct_text=parsed from sol + * - 10 rows in test_questions (linking) + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const db = require('../src/db/db'); + +const SUBJECT_ID = 3; // math (per import-content.js mapping) +const VARIANTS_DIR = path.join(__dirname, '../../frontend/js/exam9/variants'); + +/* ── Parse answer from sol — supports both
patterns ─ */ +function parseAnswer(sol) { + if (!sol) return null; + const m = sol.match(/
([\s\S]*?)<\/div>/); + if (!m) return null; + // Remove "Ответ:" prefix and HTML entities/spaces + let raw = m[1].replace(/<[^>]+>/g, '').replace(/ | /g, ' ').trim(); + raw = raw.replace(/^Ответ[:\s]*/i, '').trim(); + return raw; +} + +/* ── For OPTS tasks: parse the answer letter (а/б/в/г/д) ─ */ +function parseAnswerLetter(sol) { + const ans = parseAnswer(sol); + if (!ans) return null; + const m = ans.match(/^([а-дa-e])\s*[\)\.]/i); + return m ? m[1].toLowerCase() : null; +} + +/* ── Find or create an admin user to own the imported tests ─ */ +function findCreatedBy() { + const admin = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get(); + if (admin) return admin.id; + const sys = db.prepare("SELECT id FROM users WHERE email='system@learnspace' OR email='admin@learnspace' LIMIT 1").get(); + if (sys) return sys.id; + throw new Error('No admin user found — create one first via npm run seed'); +} + +/* ── Idempotent cleanup ─ */ +function cleanupExistingExam9() { + const oldTestIds = db.prepare('SELECT test_id FROM exam9_variant_tests').all().map(r => r.test_id); + if (!oldTestIds.length) return 0; + + // Delete questions linked only to exam9 tests (avoid touching shared questions) + const placeholders = oldTestIds.map(() => '?').join(','); + const orphanQuestions = db.prepare(` + SELECT DISTINCT q.id FROM questions q + JOIN test_questions tq ON tq.question_id = q.id + WHERE tq.test_id IN (${placeholders}) + AND NOT EXISTS ( + SELECT 1 FROM test_questions tq2 + WHERE tq2.question_id = q.id + AND tq2.test_id NOT IN (${placeholders}) + ) + `).all(...oldTestIds, ...oldTestIds).map(r => r.id); + + db.transaction(() => { + // Tests cascade-delete test_questions and exam9_variant_tests rows + db.prepare(`DELETE FROM tests WHERE id IN (${placeholders})`).run(...oldTestIds); + // Now delete orphaned questions (their options cascade) + if (orphanQuestions.length) { + const qph = orphanQuestions.map(() => '?').join(','); + db.prepare(`DELETE FROM questions WHERE id IN (${qph})`).run(...orphanQuestions); + } + })(); + + return oldTestIds.length; +} + +/* ── Load a variant from its .js file via Function constructor ─ */ +function loadVariant(n) { + const nn = String(n).padStart(2, '0'); + const src = fs.readFileSync(path.join(VARIANTS_DIR, `v${nn}.js`), 'utf8'); + const scope = {}; + new Function('VARIANTS', src)(scope); + return scope[n]; +} + +/* ── Import a single variant ─ */ +function importVariant(n, createdBy) { + const v = loadVariant(n); + if (!v || !Array.isArray(v.tasks) || !v.tasks.length) + throw new Error(`Variant ${n}: malformed (no tasks)`); + + const testTitle = `Экзамен 9 — Вариант ${n}`; + const desc = `Экзаменационный вариант №${n} (математика, 9 класс). ${v.tasks.length} задач с разбором.`; + + return db.transaction(() => { + const { lastInsertRowid: testId } = db.prepare( + `INSERT INTO tests (title, subject_slug, description, created_by, show_answers, time_limit) + VALUES (?, 'math', ?, ?, 1, NULL)` + ).run(testTitle, desc, createdBy); + + db.prepare('INSERT INTO exam9_variant_tests (variant, test_id) VALUES (?, ?)').run(n, testId); + + const insQ = db.prepare( + `INSERT INTO questions (subject_id, text, type, correct_text, explanation, allow_html, source_type, year) + VALUES (?, ?, ?, ?, ?, 1, 'экзамен 9', 2025)` + ); + const insOpt = db.prepare( + 'INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)' + ); + const insTQ = db.prepare( + 'INSERT INTO test_questions (test_id, question_id, order_index) VALUES (?,?,?)' + ); + + let questionCount = 0, optsCount = 0; + v.tasks.forEach((t, i) => { + const textWithFigure = t.figure ? `${t.text}
${t.figure}
` : t.text; + const explanation = t.sol || ''; + + if (t.opts && t.opts.length) { + const correctLetter = parseAnswerLetter(t.sol); + const { lastInsertRowid: qid } = insQ.run( + SUBJECT_ID, textWithFigure, 'single', null, explanation + ); + t.opts.forEach(([letter, optText], idx) => { + const isCorrect = correctLetter && letter.toLowerCase() === correctLetter ? 1 : 0; + insOpt.run(qid, optText, isCorrect, idx); + optsCount++; + }); + insTQ.run(testId, qid, i); + questionCount++; + } else { + const correctText = parseAnswer(t.sol); + const { lastInsertRowid: qid } = insQ.run( + SUBJECT_ID, textWithFigure, 'short_answer', correctText, explanation + ); + insTQ.run(testId, qid, i); + questionCount++; + } + }); + + return { testId, questionCount, optsCount }; + })(); +} + +/* ── Main ─ */ +function main() { + const all = process.argv.includes('--all'); + const variantNumbers = all + ? Array.from({ length: 80 }, (_, i) => i + 1) + : Array.from({ length: 40 }, (_, i) => i * 2 + 1); // 1, 3, 5, ..., 79 + + console.log(`[exam9-import] Target: ${variantNumbers.length} variants (${all ? 'ALL' : 'ODD'})`); + + const createdBy = findCreatedBy(); + console.log(`[exam9-import] Owner user_id: ${createdBy}`); + + const cleaned = cleanupExistingExam9(); + if (cleaned) console.log(`[exam9-import] Cleaned ${cleaned} existing exam9 test(s)`); + + let totalQ = 0, totalOpts = 0, errors = 0; + for (const n of variantNumbers) { + try { + const { questionCount, optsCount, testId } = importVariant(n, createdBy); + console.log(` v${String(n).padStart(2, '0')} → test #${testId} · ${questionCount} q · ${optsCount} opts`); + totalQ += questionCount; + totalOpts += optsCount; + } catch (e) { + console.error(` v${String(n).padStart(2, '0')} FAILED: ${e.message}`); + errors++; + } + } + + console.log(`\n[exam9-import] Done: ${variantNumbers.length - errors}/${variantNumbers.length} variants, ${totalQ} questions, ${totalOpts} options`); + if (errors) process.exit(2); +} + +main(); diff --git a/backend/src/controllers/sessionController.js b/backend/src/controllers/sessionController.js index 22d9532..a8e800a 100644 --- a/backend/src/controllers/sessionController.js +++ b/backend/src/controllers/sessionController.js @@ -417,7 +417,7 @@ function loadQuestionsForSession(ids) { const ph = _placeholders(ids.length); const questions = db.prepare( - `SELECT id, text, type, difficulty FROM questions WHERE id IN (${ph})` + `SELECT id, text, type, difficulty, allow_html, image FROM questions WHERE id IN (${ph})` ).all(...ids); const allOptions = db.prepare( @@ -449,7 +449,7 @@ function buildReview(session_id) { const ph = _placeholders(ids.length); const questions = db.prepare( - `SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${ph})` + `SELECT id, text, type, explanation, correct_text, allow_html, image FROM questions WHERE id IN (${ph})` ).all(...ids); const answers = db.prepare( diff --git a/backend/src/db/migrations/003_exam9_questions.sql b/backend/src/db/migrations/003_exam9_questions.sql new file mode 100644 index 0000000..a2210de --- /dev/null +++ b/backend/src/db/migrations/003_exam9_questions.sql @@ -0,0 +1,8 @@ +-- Allow HTML in question text and explanation (for curated content with formulas, SVG, etc.) +ALTER TABLE questions ADD COLUMN allow_html INTEGER NOT NULL DEFAULT 0; + +-- Mapping: exam9 variant number → test_id (so teachers can assign a variant as homework) +CREATE TABLE exam9_variant_tests ( + variant INTEGER PRIMARY KEY, + test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE +); diff --git a/backend/src/routes/assignments.js b/backend/src/routes/assignments.js index 0c98679..ff12ad2 100644 --- a/backend/src/routes/assignments.js +++ b/backend/src/routes/assignments.js @@ -31,10 +31,10 @@ const directSchema = { body: { }}; const bulkSchema = { body: { - title: { type: 'string', required: true, minLen: 1, maxLen: 200 }, - class_id: { type: 'number', required: true, min: 1 }, - mode: { type: 'string', oneOf: MODES }, - count: { type: 'number', min: 1, max: 200 }, + title: { type: 'string', required: true, minLen: 1, maxLen: 200 }, + class_ids: { type: 'array', required: true }, + mode: { type: 'string', oneOf: MODES }, + count: { type: 'number', min: 1, max: 200 }, }}; router.use(authMiddleware); diff --git a/backend/src/routes/exam9.js b/backend/src/routes/exam9.js new file mode 100644 index 0000000..7311c80 --- /dev/null +++ b/backend/src/routes/exam9.js @@ -0,0 +1,18 @@ +'use strict'; +const router = require('express').Router(); +const db = require('../db/db'); +const { authMiddleware } = require('../middleware/auth'); + +router.use(authMiddleware); + +/* GET /api/exam9/variants — { variant: test_id } map for assigning variants as homework */ +router.get('/variants', (_req, res) => { + const rows = db.prepare( + 'SELECT evt.variant, evt.test_id FROM exam9_variant_tests evt JOIN tests t ON t.id = evt.test_id ORDER BY evt.variant' + ).all(); + const map = {}; + for (const r of rows) map[r.variant] = r.test_id; + res.json({ variants: map }); +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index ebca0c6..552b1de 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -50,6 +50,7 @@ const petRoutes = require('./routes/pet'); const collectionRoutes = require('./routes/collection'); const redBookRoutes = require('./routes/red-book'); const parentRoutes = require('./routes/parent'); +const exam9Routes = require('./routes/exam9'); const { requestId, errorHandler } = require('./middleware/errorHandler'); @@ -166,6 +167,7 @@ app.use('/api/collection', collectionRoutes); app.use('/api/red-book', redBookRoutes); app.use('/api/biochem', require('./routes/biochem')); app.use('/api/parent', parentRoutes); +app.use('/api/exam9', exam9Routes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/frontend/exam9.html b/frontend/exam9.html index e879b71..fe6ae7c 100644 --- a/frontend/exam9.html +++ b/frontend/exam9.html @@ -181,6 +181,76 @@ } .ex-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); } + /* ── Assign button + modal ── */ + .ex-assign-row { + display:flex; align-items:center; gap:12px; margin-bottom:22px; flex-wrap:wrap; + } + .ex-assign-btn { + display:inline-flex; align-items:center; gap:7px; + padding:8px 16px; border:1.5px solid var(--violet); border-radius:10px; + background:rgba(155,93,229,.08); color:var(--violet); + font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700; + cursor:pointer; transition:all .15s; + } + .ex-assign-btn:hover { background:var(--violet); color:#fff; } + .ex-assign-btn:disabled { + opacity:.5; cursor:not-allowed; background:transparent; color:var(--text-3); border-color:var(--border); + } + .ex-assign-btn svg { width:14px; height:14px; } + .ex-assign-note { + font-size:.78rem; color:var(--text-3); + } + + .ax-form { display:flex; flex-direction:column; gap:14px; } + .ax-field label { + display:block; font-size:.78rem; font-weight:700; color:var(--text-2); + text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; + } + .ax-classes { + display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto; + border:1.5px solid var(--border); border-radius:10px; padding:8px; + } + .ax-class { + display:flex; align-items:center; gap:10px; padding:8px 10px; + border-radius:8px; cursor:pointer; transition:background .12s; + font-size:.9rem; + } + .ax-class:hover { background:var(--border); } + .ax-class input { accent-color:var(--violet); flex-shrink:0; } + .ax-class .ax-cname { font-weight:600; } + .ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; } + .ax-input { + width:100%; padding:9px 12px; border:1.5px solid var(--border-h); + border-radius:9px; background:var(--surface); color:var(--text); + font-family:'Manrope',sans-serif; font-size:.9rem; + } + .ax-input:focus { outline:none; border-color:var(--violet); } + .ax-actions { + display:flex; gap:10px; justify-content:flex-end; margin-top:6px; + } + .ax-btn { + padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h); + background:transparent; color:var(--text); + font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700; + cursor:pointer; transition:all .15s; + } + .ax-btn:hover { border-color:var(--text-2); } + .ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; } + .ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; } + .ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; } + .ax-error { + padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1); + border:1px solid rgba(241,91,68,.3); color:#F94144; + font-size:.84rem; display:none; + } + .ax-error.visible { display:block; } + .ax-success { + padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1); + border:1px solid rgba(6,214,160,.3); color:#06D6A0; + font-size:.84rem; display:none; + } + .ax-success.visible { display:block; } + @media (max-width: 600px) { .ex-wrap { padding:20px 16px 60px; } .ex-title { font-size:1.15rem; } @@ -241,6 +311,33 @@
+
+
+
+

Назначить вариант

+ +
+
+
+ +
Загрузка…
+
+
+ + +
+
+
+
+ + +
+
+
+
+ diff --git a/frontend/js/exam9/app.js b/frontend/js/exam9/app.js index d04e6c0..89c11b1 100644 --- a/frontend/js/exam9/app.js +++ b/frontend/js/exam9/app.js @@ -7,6 +7,9 @@ const STORAGE_KEY = 'exam9_progress_v1'; let currentVariant = null; let katexLoaded = false; +let variantTests = {}; // { variantNum: testId } — populated by /api/exam9/variants +let userRole = null; // populated by LS.getUser() +let teacherClasses = null; // lazy-loaded from /api/classes /* ── KaTeX bootstrap ────────────────────────────────────────────── */ function onKatexLoad() { @@ -110,8 +113,21 @@ function renderVariant(num) { return; } + const isTeacher = userRole === 'teacher' || userRole === 'admin'; + const testId = variantTests[num]; + const assignBtn = isTeacher + ? `
+ + ${testId ? '' : 'Этот вариант ещё не импортирован в банк (только нечётные)'} +
` + : ''; + main.innerHTML = `
${v.label}${v.tasks.length} заданий
` + + assignBtn + v.tasks.map((t, i) => `
@@ -159,13 +175,129 @@ function selectVariant(num) { window.scrollTo({ top: 0, behavior: 'smooth' }); } +/* ── Assignment modal ───────────────────────────────────────────── */ +let assignVariantNum = null; + +async function loadTeacherClasses() { + if (teacherClasses) return teacherClasses; + try { + const list = await LS.api('/api/classes'); + teacherClasses = Array.isArray(list) ? list : []; + } catch { + teacherClasses = []; + } + return teacherClasses; +} + +async function openAssignModal(variantNum) { + if (!variantTests[variantNum]) return; + assignVariantNum = variantNum; + document.getElementById('assign-title').textContent = `Назначить «Вариант ${variantNum}» как ДЗ`; + document.getElementById('ax-error').classList.remove('visible'); + document.getElementById('ax-success').classList.remove('visible'); + document.getElementById('ax-deadline').value = ''; + document.getElementById('ax-submit').disabled = false; + document.getElementById('ax-submit').textContent = 'Назначить'; + + const listEl = document.getElementById('ax-classes-list'); + listEl.textContent = 'Загрузка…'; + const classes = await loadTeacherClasses(); + if (!classes.length) { + listEl.innerHTML = '
У вас пока нет классов. Создайте класс на странице «Классы».
'; + } else { + listEl.innerHTML = classes.map(c => ` + `).join(''); + } + + document.getElementById('assign-overlay').classList.add('visible'); + document.addEventListener('keydown', onAssignEsc); +} + +function closeAssignModal() { + document.getElementById('assign-overlay').classList.remove('visible'); + document.removeEventListener('keydown', onAssignEsc); + assignVariantNum = null; +} + +function onAssignOverlayClick(e) { + if (e.target === document.getElementById('assign-overlay')) closeAssignModal(); +} +function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); } + +function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); +} + +async function submitAssign() { + const errorEl = document.getElementById('ax-error'); + const successEl = document.getElementById('ax-success'); + const submitBtn = document.getElementById('ax-submit'); + errorEl.classList.remove('visible'); + successEl.classList.remove('visible'); + + const checked = Array.from(document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')) + .map(el => Number(el.value)); + if (!checked.length) { + errorEl.textContent = 'Выберите хотя бы один класс'; + errorEl.classList.add('visible'); + return; + } + + const testId = variantTests[assignVariantNum]; + const deadline = document.getElementById('ax-deadline').value || null; + if (!testId) { errorEl.textContent = 'Вариант не в банке вопросов'; errorEl.classList.add('visible'); return; } + + submitBtn.disabled = true; + submitBtn.textContent = 'Назначаю…'; + + try { + const r = await LS.api('/api/assignments/bulk', { + method: 'POST', + body: { + title: `Экзамен 9 — Вариант ${assignVariantNum}`, + class_ids: checked, + mode: 'exam', + count: 10, + test_id: testId, + deadline: deadline, + is_homework: 1, + }, + }); + successEl.textContent = `Назначено в ${r.count || checked.length} классе(ах)`; + successEl.classList.add('visible'); + submitBtn.textContent = 'Готово'; + setTimeout(closeAssignModal, 1500); + } catch (e) { + errorEl.textContent = e.message || 'Не удалось создать задание'; + errorEl.classList.add('visible'); + submitBtn.disabled = false; + submitBtn.textContent = 'Назначить'; + } +} + /* ── Boot ───────────────────────────────────────────────────────── */ -(function boot() { +(async function boot() { const keys = Object.keys(VARIANTS); if (!keys.length) { document.getElementById('ex-main').innerHTML = '
Варианты не загружены
'; return; } + + // Load user role + variant-to-test map (parallel) + const user = (typeof LS !== 'undefined') ? LS.getUser?.() : null; + userRole = user?.role || null; + + if (userRole === 'teacher' || userRole === 'admin') { + try { + const r = await LS.api('/api/exam9/variants'); + variantTests = r.variants || {}; + } catch { variantTests = {}; } + } + // Resume last opened variant or open first one let initial = Number(keys[0]); try { diff --git a/frontend/test-result.html b/frontend/test-result.html index f9580d5..f85adbf 100644 --- a/frontend/test-result.html +++ b/frontend/test-result.html @@ -160,6 +160,8 @@ delimiters: [ { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true }, + { left: '$$', right: '$$', display: true }, + { left: '$', right: '$', display: false }, ], throwOnError: false, }; @@ -312,7 +314,7 @@ if (isCorrect && isChosen) { cls = 'chosen-correct'; icon = lsIcon('check', 14); } else if (isCorrect) { cls = 'correct-opt'; icon = lsIcon('check', 14); } else if (isChosen) { cls = 'chosen-wrong'; icon = lsIcon('x', 14); } - return `
${icon}${esc(o.text)}
`; + return `
${icon}${q.allow_html ? o.text : esc(o.text)}
`; }).join('') + '
'; } else if (type === 'matching') { const pairs = (() => { try { return JSON.parse(q.answer_text || '{}'); } catch { return {}; } })(); @@ -334,13 +336,14 @@ let cls = '', icon = ''; if (isCorrect) { cls = 'correct-opt'; icon = lsIcon('check', 14); } else if (isChosen && !isCorrect) { cls = 'chosen-wrong'; icon = lsIcon('x', 14); } - return `
${icon}${esc(o.text)}
`; + return `
${icon}${q.allow_html ? o.text : esc(o.text)}
`; }).join('') + '
'; } const expl = q.explanation - ? `
Пояснение: ${esc(q.explanation)}
` + ? `
Пояснение: ${q.allow_html ? q.explanation : esc(q.explanation)}
` : ''; + const qText = q.allow_html ? q.text : esc(q.text); list.innerHTML += `
@@ -348,7 +351,7 @@ Вопрос ${i + 1} ${badgeText[status]}
-
${esc(q.text)}
+
${qText}
${bodyHtml} ${expl} `; diff --git a/frontend/test-run.html b/frontend/test-run.html index 6653475..fef5c08 100644 --- a/frontend/test-run.html +++ b/frontend/test-run.html @@ -342,6 +342,8 @@ delimiters: [ { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true }, + { left: '$$', right: '$$', display: true }, + { left: '$', right: '$', display: false }, ], throwOnError: false, }); @@ -409,7 +411,7 @@ tabindex="0" data-opt-id="${opt.id}" data-i="${i}"> -
${esc(opt.text)}
+
${q.allow_html ? opt.text : esc(opt.text)}
`; }).join(''); bodyHtml = `
${optHtml}
`; @@ -434,7 +436,7 @@ ${flags[q.id] ? 'отмечен' : ''} ${q.image ? `` : ''} -

${esc(q.text)}

+ ${q.allow_html ? `
${q.text}
` : `

${esc(q.text)}

`} ${bodyHtml}