feat: exam9 — назначение варианта как ДЗ + импорт нечётных в банк
Импорт 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)
This commit is contained in:
@@ -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 <div class="sol-ans">…</div> patterns ─ */
|
||||
function parseAnswer(sol) {
|
||||
if (!sol) return null;
|
||||
const m = sol.match(/<div class="sol-ans">([\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}<div class="task-figure" style="margin-top:12px">${t.figure}</div>` : 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();
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -32,7 +32,7 @@ const directSchema = { body: {
|
||||
|
||||
const bulkSchema = { body: {
|
||||
title: { type: 'string', required: true, minLen: 1, maxLen: 200 },
|
||||
class_id: { type: 'number', required: true, min: 1 },
|
||||
class_ids: { type: 'array', required: true },
|
||||
mode: { type: 'string', oneOf: MODES },
|
||||
count: { type: 'number', min: 1, max: 200 },
|
||||
}};
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
|
||||
<div class="ex-panel" onclick="event.stopPropagation()" style="width:min(520px,94vw)">
|
||||
<div class="ex-panel-head">
|
||||
<h2 id="assign-title">Назначить вариант</h2>
|
||||
<button class="ex-panel-close" onclick="closeAssignModal()" title="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
||||
<div class="ax-field">
|
||||
<label>Классы</label>
|
||||
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
||||
</div>
|
||||
<div class="ax-field">
|
||||
<label>Срок сдачи (опционально)</label>
|
||||
<input type="datetime-local" class="ax-input" id="ax-deadline" />
|
||||
</div>
|
||||
<div class="ax-error" id="ax-error"></div>
|
||||
<div class="ax-success" id="ax-success"></div>
|
||||
<div class="ax-actions">
|
||||
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
|
||||
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
|
||||
+133
-1
@@ -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
|
||||
? `<div class="ex-assign-row">
|
||||
<button class="ex-assign-btn" ${testId ? `onclick="openAssignModal(${num})"` : 'disabled'}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||||
Назначить как ДЗ
|
||||
</button>
|
||||
${testId ? '' : '<span class="ex-assign-note">Этот вариант ещё не импортирован в банк (только нечётные)</span>'}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
main.innerHTML =
|
||||
`<div class="variant-title">${v.label}<small>${v.tasks.length} заданий</small></div>` +
|
||||
assignBtn +
|
||||
v.tasks.map((t, i) => `
|
||||
<div class="task-card">
|
||||
<div class="task-header">
|
||||
@@ -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 = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
|
||||
} else {
|
||||
listEl.innerHTML = classes.map(c => `
|
||||
<label class="ax-class">
|
||||
<input type="checkbox" name="cls" value="${c.id}" />
|
||||
<span class="ax-cname">${escapeHtml(c.name)}</span>
|
||||
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
||||
</label>`).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 = '<div class="ex-empty">Варианты не загружены</div>';
|
||||
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 {
|
||||
|
||||
@@ -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 `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
||||
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${q.allow_html ? o.text : esc(o.text)}</div>`;
|
||||
}).join('') + '</div>';
|
||||
} else if (type === 'matching') {
|
||||
const pairs = (() => { try { return JSON.parse(q.answer_text || '{}'); } catch { return {}; } })();
|
||||
@@ -334,13 +336,14 @@
|
||||
let cls = '', icon = '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
|
||||
if (isCorrect) { cls = 'correct-opt'; icon = lsIcon('check', 14); }
|
||||
else if (isChosen && !isCorrect) { cls = 'chosen-wrong'; icon = lsIcon('x', 14); }
|
||||
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
||||
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${q.allow_html ? o.text : esc(o.text)}</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
const expl = q.explanation
|
||||
? `<div class="review-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`
|
||||
? `<div class="review-explanation"><strong>Пояснение:</strong> ${q.allow_html ? q.explanation : esc(q.explanation)}</div>`
|
||||
: '';
|
||||
const qText = q.allow_html ? q.text : esc(q.text);
|
||||
|
||||
list.innerHTML += `
|
||||
<div class="review-item ${status}">
|
||||
@@ -348,7 +351,7 @@
|
||||
<span class="review-qnum">Вопрос ${i + 1}</span>
|
||||
<span class="review-badge ${status}">${badgeText[status]}</span>
|
||||
</div>
|
||||
<div class="review-text">${esc(q.text)}</div>
|
||||
<div class="review-text">${qText}</div>
|
||||
${bodyHtml}
|
||||
${expl}
|
||||
</div>`;
|
||||
|
||||
@@ -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}">
|
||||
<div class="q-opt-key" aria-hidden="true">${keyLabel}</div>
|
||||
<div class="q-opt-text">${esc(opt.text)}</div>
|
||||
<div class="q-opt-text">${q.allow_html ? opt.text : esc(opt.text)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
bodyHtml = `<div class="q-options" id="opts" role="${isMulti ? 'group' : 'radiogroup'}" aria-label="Варианты ответа">${optHtml}</div>`;
|
||||
@@ -434,7 +436,7 @@
|
||||
${flags[q.id] ? '<span style="font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px">отмечен</span>' : ''}
|
||||
</div>
|
||||
${q.image ? `<img src="${esc(q.image)}" alt="" style="max-width:100%;max-height:260px;border-radius:10px;margin-bottom:12px;display:block" />` : ''}
|
||||
<p class="q-text">${esc(q.text)}</p>
|
||||
${q.allow_html ? `<div class="q-text">${q.text}</div>` : `<p class="q-text">${esc(q.text)}</p>`}
|
||||
${bodyHtml}
|
||||
<div class="q-nav">
|
||||
<button class="btn-nav" id="btn-prev" ${idx === 0 ? 'disabled' : ''}><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Назад</button>
|
||||
|
||||
Reference in New Issue
Block a user