feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»

Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 11:03:42 +03:00
parent c5d440a7a9
commit c6d323ec6d
4 changed files with 75 additions and 13 deletions
+22 -13
View File
@@ -7,13 +7,16 @@ function list(req, res) {
const args = []; const args = [];
let where = '1=1'; let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); } if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); } const isStudent = role === 'student' || role === 'free_student';
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
if (isStudent) { where += ' AND t.available_to_students = 1'; }
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js), // Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки. // не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)'; where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(` let rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
u.name AS creator_name, u.name AS creator_name,
COUNT(tq.question_id) AS question_count COUNT(tq.question_id) AS question_count
FROM tests t FROM tests t
@@ -22,18 +25,19 @@ function list(req, res) {
WHERE ${where} WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args); `).all(...args);
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
res.json(rows); res.json(rows);
} }
/* ── POST /api/tests ─────────────────────────────────────────────────────── */ /* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) { function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body; const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' }); if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' }); if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null; const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare( const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id); ).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
res.status(201).json({ id: r.lastInsertRowid }); res.status(201).json({ id: r.lastInsertRowid });
} }
@@ -76,13 +80,18 @@ function getOne(req, res) {
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */ /* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) { function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body; const b = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined; // Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?') // присылающий только available_to_students, обнулил бы title/subject и т.п.).
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0), const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
tl !== undefined ? tl : t.time_limit, const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
t.id); const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
res.json({ ok: true }); res.json({ ok: true });
} }
@@ -0,0 +1,4 @@
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
+32
View File
@@ -1750,6 +1750,11 @@
<div class="widget" id="w-tests"> <div class="widget" id="w-tests">
<div class="w-head"><div class="w-title">Тесты</div></div> <div class="w-head"><div class="w-title">Тесты</div></div>
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div> <div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
<div class="subj-mini-grid" id="available-tests-list"></div>
</div>
</div> </div>
<!-- Col 3: Progress --> <!-- Col 3: Progress -->
@@ -2257,6 +2262,32 @@
window.location.href = url; window.location.href = url;
} }
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
async function loadAvailableTests() {
const wrap = document.getElementById('avail-tests-wrap');
const list = document.getElementById('available-tests-list');
if (!wrap || !list) return;
try {
const tests = await LS.getTests();
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
list.innerHTML = tests.map((t, i) => {
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
const iconName = ICONS[t.subject_slug] || 'book-open';
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(t.title)}</div>
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
wrap.style.display = '';
reIcons();
} catch { wrap.style.display = 'none'; }
}
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */ /* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
async function loadAssignments() { async function loadAssignments() {
try { try {
@@ -4505,6 +4536,7 @@
} else { } else {
// Student: full layout // Student: full layout
loadSubjects(); loadSubjects();
loadAvailableTests();
loadAssignments(); loadAssignments();
loadStats(); loadStats();
loadGamification(); loadGamification();
+17
View File
@@ -40,10 +40,12 @@
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span> <span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span> <span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span> <span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''} ${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div> </div>
</div> </div>
<div class="q-card-actions"> <div class="q-card-actions">
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button> <button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button> <button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div> </div>
@@ -261,6 +263,20 @@
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
} }
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
async function toggleTstAvail(id) {
const t = allTests.find(x => x.id === id);
if (!t) return;
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
const next = t.available_to_students ? 0 : 1;
try {
await LS.updateTest(id, { available_to_students: next });
t.available_to_students = next;
renderTests();
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers // Expose handlers
window.loadTests = load; window.loadTests = load;
window.renderTests = renderTests; window.renderTests = renderTests;
@@ -274,6 +290,7 @@
window.closeTstModal = closeTstModal; window.closeTstModal = closeTstModal;
window.saveTst = saveTst; window.saveTst = saveTst;
window.deleteTst = deleteTst; window.deleteTst = deleteTst;
window.toggleTstAvail = toggleTstAvail;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = { window.AdminSections.tests = {