Compare commits
49 Commits
70cf6b3af1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7562d1a77b | |||
| 1707a510a9 | |||
| e70bf819ce | |||
| 48158ea88d | |||
| fe6df8fb98 | |||
| 244df71aec | |||
| 5bb0aeb940 | |||
| dfa0535b63 | |||
| cefb5e0836 | |||
| 5eed248702 | |||
| d395e1083b | |||
| 40df8893cc | |||
| 8027d9fda0 | |||
| 43df41287f | |||
| db1db68488 | |||
| 3c45c606bf | |||
| 1649d6c2ec | |||
| 4b5be8442b | |||
| 3898080f04 | |||
| efba722977 | |||
| be9fdfa703 | |||
| 758e1bf6cb | |||
| 0d4c658d93 | |||
| 5a4bc48027 | |||
| 73ba5a3530 | |||
| a7f2ae9937 | |||
| 748b0aaab1 | |||
| 22c7b38e9a | |||
| 205290139d | |||
| c6d323ec6d | |||
| c5d440a7a9 | |||
| 1aa95a6776 | |||
| 399a222b65 | |||
| 796a2416cb | |||
| 604fa7ac0b | |||
| 38f8be9389 | |||
| c04a8c2178 | |||
| 83f0ba9c04 | |||
| d5fbd0168e | |||
| 54be84e74a | |||
| dc71d7b4d9 | |||
| d8f2a7f98d | |||
| 9d35aaf673 | |||
| bd7dd06e47 | |||
| f381873c34 | |||
| dd69c026ec | |||
| 84625cd72a | |||
| 0fb16ef85e | |||
| b9a82c326e |
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
fix_ctmath_render.js — починка двух дефектов рендеринга в exam_tasks (ctmath).
|
||||
|
||||
ПРИЧИНА (root cause):
|
||||
В seed-скриптах вариантов 101–121 опции писались как mc('$\sqrt{17}$', ...) —
|
||||
в ОБЫЧНЫХ кавычках, а не в String.raw `…`. JS-парсер съедал управляющие
|
||||
эскейпы: \s→s, \d→d (теряется «\»), а \f→0x0C, \t→0x09, \b→0x08, \v,\n,\r —
|
||||
превращались в УПРАВЛЯЮЩИЕ символы. Итог в БД: «$sqrt{17}$», «$dfrac{pi}{3}$»,
|
||||
KaTeX рендерит их как «sqrt17», «dfracpi3». (text/solution писались через R`…`
|
||||
и НЕ пострадали — там «\» на месте.)
|
||||
|
||||
ВТОРОЙ ДЕФЕКТ: литеральные < и > ВНУТРИ $…$ (напр. «$-1{,}6<x<-1$»). При вставке
|
||||
в innerHTML браузер парсит «<x…» как HTML-тег ДО запуска KaTeX → ломает карточку.
|
||||
Лечится заменой < → \lt, > → \gt (только внутри $…$).
|
||||
|
||||
ЧТО ДЕЛАЕТ СКРИПТ (идемпотентно, повторный запуск безопасен):
|
||||
• opts_json: (1) нормализует управляющие символы обратно в \f \t \b \v \n \r;
|
||||
(2) восстанавливает «\» перед известными KaTeX-командами; (3) < > → \lt \gt.
|
||||
• text_html, solution_html: только (3) < > → \lt \gt внутри $…$ (HTML-теги вне
|
||||
математики не трогаются).
|
||||
Восстановление «\» применяется ТОЛЬКО к opts_json (text/sol не повреждены).
|
||||
|
||||
Запуск:
|
||||
node backend/scripts/fix_ctmath_render.js # DRY-RUN (показывает правки)
|
||||
node backend/scripts/fix_ctmath_render.js --apply # запись в БД
|
||||
⚠️ Запись запускает ПОЛЬЗОВАТЕЛЬ. После --apply — перезапуск сервера не нужен
|
||||
(данные в БД; фронт перечитает их при следующем запросе), но hard-refresh браузера.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
|
||||
/* ── Управляющие символы → их LaTeX-эскейп (обратная нормализация) ── */
|
||||
const CTRL_MAP = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\v': '\\v', '\f': '\\f', '\r': '\\r' };
|
||||
function normalizeCtrl(s) {
|
||||
return s.replace(/[\b\t\n\v\f\r]/g, ch => CTRL_MAP[ch] || ch);
|
||||
}
|
||||
|
||||
/* ── Команды, у которых первая буква НЕ из {f,t,b,v,n,r} (их «\» просто пропал,
|
||||
без управляющего символа). Длинные/составные — РАНЬШЕ коротких префиксов,
|
||||
чтобы не разорвать слово (dfrac до frac, arccos до cos, leq до le, …). ── */
|
||||
const BARE_CMDS = [
|
||||
'arccos', 'arcsin', 'arctg',
|
||||
'overline', 'operatorname', 'varnothing', 'varphi', 'varepsilon',
|
||||
'dfrac', 'cdots', 'cdot', 'sqrt', 'left',
|
||||
'lambda', 'gamma', 'delta', 'sigma', 'omega', 'alpha', 'angle', 'approx',
|
||||
'infty', 'ldots', 'oplus',
|
||||
'cos', 'sin', 'cot', 'ctg', 'cup', 'cap', 'leq', 'geq', 'neq',
|
||||
'sim', 'lim', 'log',
|
||||
'pm', 'mp', 'le', 'ge', 'ln', 'lg', 'pi', 'mu', 'in',
|
||||
'phi', 'psi', 'rho', 'chi', 'tau',
|
||||
];
|
||||
/* (frac, tfrac, times, theta, tan, tg, text, beta, vec, ne, nu, right, nabla —
|
||||
приходят из управляющих символов и чинятся normalizeCtrl, поэтому в этом
|
||||
списке их НЕТ: иначе «dfrac»→«d\frac».) */
|
||||
|
||||
function restoreBackslashes(math) {
|
||||
let s = normalizeCtrl(math);
|
||||
for (const cmd of BARE_CMDS) {
|
||||
// «\bcmd», не уже-экранированное (нет «\» перед), как самостоятельное слово
|
||||
const re = new RegExp('(^|[^\\\\A-Za-z])' + cmd + '(?![A-Za-z])', 'g');
|
||||
s = s.replace(re, (m, pre) => pre + '\\' + cmd);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── < > → \lt \gt ТОЛЬКО внутри $…$ / $$…$$ ── */
|
||||
function fixAngles(field) {
|
||||
if (!field) return field;
|
||||
return String(field).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg =>
|
||||
seg.replace(/</g, '\\lt ').replace(/>/g, '\\gt '));
|
||||
}
|
||||
|
||||
/* ── Полная починка одной опции (внутри $…$): \ + < > ── */
|
||||
function fixOptionText(t) {
|
||||
if (!t) return t;
|
||||
// обрабатываем содержимое каждого $…$: восстановить «\», затем < >
|
||||
return String(t).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg => {
|
||||
const open = seg.startsWith('$$') ? '$$' : '$';
|
||||
const inner = seg.slice(open.length, seg.length - open.length);
|
||||
let fixed = restoreBackslashes(inner);
|
||||
fixed = fixed.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
|
||||
return open + fixed + open;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Открытие БД ── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT id, variant, task_idx, task_type, text_html, opts_json, solution_html
|
||||
FROM exam_tasks WHERE exam_key=? ORDER BY variant, task_idx`).all(EXAM);
|
||||
|
||||
let changedRows = 0, changedOpts = 0, changedText = 0, changedSol = 0;
|
||||
const samples = [];
|
||||
|
||||
const upd = db.prepare(
|
||||
`UPDATE exam_tasks SET text_html=?, opts_json=?, solution_html=? WHERE id=?`);
|
||||
|
||||
if (APPLY) db.exec('BEGIN');
|
||||
try {
|
||||
for (const r of rows) {
|
||||
let newOpts = r.opts_json;
|
||||
let newText = r.text_html;
|
||||
let newSol = r.solution_html;
|
||||
let touched = false;
|
||||
|
||||
// opts_json — восстановление «\» + < >
|
||||
if (r.opts_json) {
|
||||
try {
|
||||
const arr = JSON.parse(r.opts_json);
|
||||
const fixed = arr.map(([l, t]) => [l, fixOptionText(t)]);
|
||||
const cand = JSON.stringify(fixed);
|
||||
if (cand !== r.opts_json) { newOpts = cand; changedOpts++; touched = true; }
|
||||
} catch { /* не-JSON — пропускаем */ }
|
||||
}
|
||||
// text_html / solution_html — только < >
|
||||
const ft = fixAngles(r.text_html);
|
||||
if (ft !== r.text_html) { newText = ft; changedText++; touched = true; }
|
||||
const fs = fixAngles(r.solution_html);
|
||||
if (fs !== r.solution_html) { newSol = fs; changedSol++; touched = true; }
|
||||
|
||||
if (touched) {
|
||||
changedRows++;
|
||||
if (samples.length < 12) {
|
||||
samples.push({ v: r.variant, i: r.task_idx,
|
||||
beforeOpts: r.opts_json && r.opts_json.length > 90 ? r.opts_json.slice(0, 90) + '…' : r.opts_json,
|
||||
afterOpts: newOpts && newOpts.length > 90 ? newOpts.slice(0, 90) + '…' : newOpts });
|
||||
}
|
||||
if (APPLY) upd.run(newText, newOpts, newSol, r.id);
|
||||
}
|
||||
}
|
||||
if (APPLY) db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
if (APPLY) db.exec('ROLLBACK');
|
||||
console.error('✗ Ошибка, откат:', e.message);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n=== fix_ctmath_render (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===`);
|
||||
console.log(`Всего задач ctmath: ${rows.length}`);
|
||||
console.log(`Будет изменено строк: ${changedRows} (opts: ${changedOpts}, text: ${changedText}, sol: ${changedSol})`);
|
||||
console.log(`\nПримеры (opts до → после):`);
|
||||
for (const s of samples) {
|
||||
console.log(`\n v${s.v}#${s.i}`);
|
||||
console.log(` ДО: ${s.beforeOpts}`);
|
||||
console.log(` ПОСЛЕ: ${s.afterOpts}`);
|
||||
}
|
||||
|
||||
/* контроль остаточных «голых» команд после починки (для self-check в dry-run) */
|
||||
if (!APPLY) {
|
||||
const after = db.prepare(`SELECT opts_json FROM exam_tasks WHERE exam_key=? AND opts_json IS NOT NULL`).all(EXAM);
|
||||
let leftover = 0;
|
||||
for (const r of after) {
|
||||
let arr; try { arr = JSON.parse(r.opts_json); } catch { continue; }
|
||||
for (const [, t] of arr) {
|
||||
const fixedNow = fixOptionText(t);
|
||||
// ищем подозрительные «\bdfrac/sqrt/frac…» БЕЗ слэша уже ПОСЛЕ починки
|
||||
if (/(^|[^\\A-Za-z])(sqrt|dfrac|frac|tfrac|cdot|times|alpha|beta|theta|pi)(?![A-Za-z])/.test(fixedNow.replace(/\$/g,''))) leftover++;
|
||||
}
|
||||
}
|
||||
console.log(`\nКонтроль: потенциально не починенных опций после прогона: ${leftover}`);
|
||||
console.log(`\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/fix_ctmath_render.js --apply\n`);
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js).
|
||||
|
||||
⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET.
|
||||
Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически).
|
||||
Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system).
|
||||
|
||||
Запуск:
|
||||
node backend/scripts/reset-system.js # план
|
||||
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
const reset = require('../src/services/systemReset');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const CONFIRM = process.argv.includes('--confirm=RESET');
|
||||
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const keptAdmin = reset.pickKeptAdmin(db);
|
||||
if (!keptAdmin) {
|
||||
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
|
||||
db.close(); process.exit(1);
|
||||
}
|
||||
|
||||
const plan = reset.classify(db);
|
||||
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`);
|
||||
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
|
||||
console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`);
|
||||
console.log('REASSIGN (контент → админу):');
|
||||
plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`));
|
||||
console.log('\nWIPE (полная очистка):');
|
||||
plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`));
|
||||
console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`);
|
||||
console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`);
|
||||
if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`);
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
|
||||
db.close(); process.exit(0);
|
||||
}
|
||||
if (!CONFIRM) {
|
||||
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
|
||||
db.close(); process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = reset.runReset(db, keptAdmin.id);
|
||||
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`);
|
||||
console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`);
|
||||
if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`);
|
||||
console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`);
|
||||
} catch (e) {
|
||||
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
|
||||
db.close(); process.exit(1);
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,352 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
seed_ctmath_ct2011_v1.js
|
||||
Чистый вариант-пробник для трека exam-prep `ctmath`.
|
||||
|
||||
Источник: Централизованное тестирование (ЦТ) по математике, 2011, Вариант 1.
|
||||
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
|
||||
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2011\ЦТ 2011 В1-В10.pdf
|
||||
(несмотря на имя «В1-В10», тест полный: А1–А18 + В1–В12; ответы — «Ответы 2011.pdf», столбец 1).
|
||||
|
||||
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
|
||||
совпадение, включая B2=150, B8=16, B10=10, B12=26. variant=121. Прогнан через
|
||||
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
|
||||
|
||||
Уточнения по таблице (скан неоднозначен по степеням/индексам):
|
||||
• А6: степень $3x+4$ → $2^{3x+4}-2^{3x}=15\cdot2^{3x}$;
|
||||
• А9: $3^{-12}\cdot(3^{-2})^{-5}=3^{-2}=\tfrac19$;
|
||||
• А7: корень уравнения с радикалом = $-3$ (корень линейного множителя вне ОДЗ отброшен);
|
||||
• А10: осевое сечение $=10$ → боковая $=10\pi$.
|
||||
Реконструкции «с-картинкой» (смысл/ответ сохранены, авто-проверка):
|
||||
• А1 (tg не определена) → точки в тексте, ровно одна вида $\tfrac{\pi}{2}+\pi k$ ($-\tfrac{5\pi}{2}$);
|
||||
• А2 (параллелограмм на сетке) → основание/высота числами ($5\times4=20$);
|
||||
• B6 (парабола+прямая) → парабола $y=x^2-6x+9$ и прямая $y=1{,}25$ заданы явно ($4x_1x_2=31$).
|
||||
Без авторских ссылок (политика «все учебники наши»).
|
||||
|
||||
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
|
||||
Запуск:
|
||||
node backend/scripts/seed_ctmath_ct2011_v1.js # DRY-RUN (по умолчанию)
|
||||
node backend/scripts/seed_ctmath_ct2011_v1.js --apply # запись в БД
|
||||
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
const VARIANT = 121;
|
||||
const N_TASKS = 30;
|
||||
const PROV = 'ЦТ–2011, Вариант 1';
|
||||
const R = String.raw;
|
||||
|
||||
const L = ['а', 'б', 'в', 'г', 'д'];
|
||||
const mc = (...html) => html.map((h, i) => [L[i], h]);
|
||||
|
||||
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
|
||||
const TASKS = [
|
||||
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
|
||||
{ idx: 1, type: 'mc', topic: 'trigonometry', subtopic: 'trig-circle', diff: 1,
|
||||
text: R`Функция $y=\operatorname{tg}x$ не определена в точке:`,
|
||||
opts: mc('$2\pi$', '$-\dfrac{5\pi}{2}$', '$\dfrac{2\pi}{5}$', '$\dfrac{\pi}{4}$', '$-3\pi$'),
|
||||
answer: 'б',
|
||||
sol: R`$\operatorname{tg}x$ не определён при $x=\dfrac{\pi}{2}+\pi k$. Из перечисленных таково $-\dfrac{5\pi}{2}=\dfrac{\pi}{2}-3\pi$.` },
|
||||
|
||||
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 1,
|
||||
text: R`Параллелограмм изображён на клетчатой бумаге с клетками $1\times1$ см: его основание равно $5$ см, а высота, проведённая к этому основанию, равна $4$ см. Найдите площадь параллелограмма (в квадратных сантиметрах).`,
|
||||
opts: mc('$10$', '$25$', '$15$', '$20$', '$18$'),
|
||||
answer: 'г',
|
||||
sol: R`Площадь параллелограмма $=$ основание $\times$ высоту $=5\cdot4=20$ см².` },
|
||||
|
||||
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
|
||||
text: R`Если $7\tfrac29:x=4\tfrac13:3\tfrac35$ — верная пропорция, то число $x$ равно:`,
|
||||
opts: mc('$\dfrac23$', '$6$', '$\dfrac54$', '$\dfrac49$', '$1{,}5$'),
|
||||
answer: 'б',
|
||||
sol: R`$x=\dfrac{7\tfrac29\cdot3\tfrac35}{4\tfrac13}=\dfrac{\tfrac{65}{9}\cdot\tfrac{18}{5}}{\tfrac{13}{3}}=\dfrac{26}{\tfrac{13}{3}}=6$.` },
|
||||
|
||||
{ idx: 4, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
|
||||
text: R`Если $15\%$ некоторого числа равны $33$, то $20\%$ этого числа равны:`,
|
||||
opts: mc('$44$', '$46$', '$55$', '$56$', '$66$'),
|
||||
answer: 'а',
|
||||
sol: R`Число $=\dfrac{33}{0{,}15}=220$, тогда $20\%=0{,}2\cdot220=44$.` },
|
||||
|
||||
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 1,
|
||||
text: R`Если $9x-24=0$, то $18x-31$ равно:`,
|
||||
opts: mc('$13$', '$-17$', '$17$', '$21$', '$-19$'),
|
||||
answer: 'в',
|
||||
sol: R`$x=\dfrac{24}{9}=\dfrac83$, поэтому $18x=48$ и $18x-31=17$.` },
|
||||
|
||||
{ idx: 6, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
|
||||
text: R`Для любого числа $x$ выражение $2^{3x+4}-2^{3x}$ равно:`,
|
||||
opts: mc('$15\cdot2^{3x}$', '$16$', '$2^{6x+1}$', '$\dfrac23\cdot2^{3x}$', '$2^{3x}$'),
|
||||
answer: 'а',
|
||||
sol: R`$2^{3x+4}-2^{3x}=2^{3x}\left(2^{4}-1\right)=15\cdot2^{3x}$.` },
|
||||
|
||||
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-irrational', diff: 2,
|
||||
text: R`Сумма корней (корень, если он один) уравнения $(x+5)\sqrt{x+3}=0$ равна:`,
|
||||
opts: mc('$-1$', '$3$', '$1$', '$-3$', '$-2$'),
|
||||
answer: 'г',
|
||||
sol: R`ОДЗ: $x\ge-3$. Корень $x=-5$ не входит в ОДЗ, остаётся $x=-3$ (из $\sqrt{x+3}=0$). Единственный корень $-3$.` },
|
||||
|
||||
{ idx: 8, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
|
||||
text: R`От листа жести, имеющего форму квадрата, отрезали прямоугольную полосу шириной $7$ дм, после чего площадь оставшейся части листа оказалась равной $30$ дм². Длина стороны квадратного листа (в дециметрах) была равна:`,
|
||||
opts: mc('$11$', '$12$', '$3$', '$9$', '$10$'),
|
||||
answer: 'д',
|
||||
sol: R`Если сторона квадрата $a$, то $a(a-7)=30$, $a^{2}-7a-30=0$, $a=10$ (второй корень $-3$ отброшен).` },
|
||||
|
||||
{ idx: 9, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
|
||||
text: R`Значение выражения $3^{-12}\cdot\left(3^{-2}\right)^{-5}$ равно:`,
|
||||
opts: mc('$81$', '$3^{-22}$', '$9$', '$3^{-12}$', '$\dfrac19$'),
|
||||
answer: 'д',
|
||||
sol: R`$\left(3^{-2}\right)^{-5}=3^{10}$, поэтому $3^{-12}\cdot3^{10}=3^{-2}=\dfrac19$.` },
|
||||
|
||||
{ idx: 10, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
|
||||
text: R`Площадь осевого сечения цилиндра равна $10$. Площадь его боковой поверхности равна:`,
|
||||
opts: mc('$5\pi$', '$10\pi$', '$20\pi$', '$100\pi$', '$10$'),
|
||||
answer: 'б',
|
||||
sol: R`Осевое сечение — прямоугольник $2r\times h$ площадью $2rh=10$. Боковая поверхность $=2\pi rh=\pi\cdot2rh=10\pi$.` },
|
||||
|
||||
{ idx: 11, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
|
||||
text: R`Найдите значение выражения $230\cdot\dfrac29-\left(\dfrac29+\dfrac1{10}\right):\dfrac1{230}$.`,
|
||||
opts: mc('$0{,}1$', '$43\tfrac49$', '$-0{,}1$', '$-23$', '$23$'),
|
||||
answer: 'г',
|
||||
sol: R`$230\cdot\dfrac29=\dfrac{460}{9}$; $\left(\dfrac{29}{90}\right)\cdot230=\dfrac{667}{9}$. Разность $\dfrac{460-667}{9}=-\dfrac{207}{9}=-23$.` },
|
||||
|
||||
{ idx: 12, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
|
||||
text: R`Упростите выражение $\dfrac{x^{2}-22x+121}{x^{2}-11x}:\dfrac{x^{2}-121}{x^{3}}$.`,
|
||||
opts: mc('$\dfrac{x}{x+11}$', '$\dfrac{(x-11)^{2}}{x^{4}}$', '$\dfrac{x-11}{x+11}$', '$\dfrac{x^{2}}{x-11}$', '$\dfrac{x^{2}}{x+11}$'),
|
||||
answer: 'д',
|
||||
sol: R`$\dfrac{(x-11)^{2}}{x(x-11)}\cdot\dfrac{x^{3}}{(x-11)(x+11)}=\dfrac{x^{2}}{x+11}$.` },
|
||||
|
||||
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
|
||||
text: R`Параллельно стороне треугольника, равной $5$, проведена прямая. Длина отрезка этой прямой, заключённого между сторонами треугольника, равна $2$. Найдите отношение площади полученной трапеции к площади исходного треугольника.`,
|
||||
opts: mc('$\dfrac25$', '$0{,}6$', '$\dfrac{21}{25}$', '$\dfrac{4}{25}$', '$\dfrac{3}{25}$'),
|
||||
answer: 'в',
|
||||
sol: R`Отсечённый треугольник подобен исходному с коэффициентом $\dfrac25$, его площадь составляет $\dfrac{4}{25}$. Трапеция: $1-\dfrac{4}{25}=\dfrac{21}{25}$.` },
|
||||
|
||||
{ idx: 14, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
|
||||
text: R`Сумма координат точки пересечения прямых, заданных уравнениями $2x+5y=11$ и $x+y=2(5-y)$, равна:`,
|
||||
opts: mc('$8$', '$-8$', '$10$', '$-10$', '$6$'),
|
||||
answer: 'б',
|
||||
sol: R`Второе уравнение: $x+3y=10$. Из системы $y=9$, $x=-17$. Сумма координат $-17+9=-8$.` },
|
||||
|
||||
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 3,
|
||||
text: R`Количество целых решений неравенства $\dfrac{(x+3)^{2}-6x-18}{(x-5)^{2}}>0$ на промежутке $[-4;5]$ равно:`,
|
||||
opts: mc('$2$', '$7$', '$4$', '$5$', '$3$'),
|
||||
answer: 'а',
|
||||
sol: R`Числитель $(x+3)^{2}-6x-18=x^{2}-9$. При $x\ne5$ знаменатель положителен, поэтому неравенство равносильно $x^{2}-9>0$, то есть $x<-3$ или $x>3$. На $[-4;5]$ это $x=-4$ и $x=4$ — $2$ решения.` },
|
||||
|
||||
{ idx: 16, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 3,
|
||||
text: R`В ромб площадью $18\sqrt5$ вписан круг площадью $5\pi$. Сторона ромба равна:`,
|
||||
opts: mc('$8$', '$18$', '$\dfrac{9\sqrt5}{5}$', '$\dfrac{18\sqrt5}{5}$', '$9$'),
|
||||
answer: 'д',
|
||||
sol: R`Радиус вписанного круга: $\pi r^{2}=5\pi$, $r=\sqrt5$; высота ромба $h=2r=2\sqrt5$. Площадь $=a\cdot h$: $18\sqrt5=a\cdot2\sqrt5$, $a=9$.` },
|
||||
|
||||
{ idx: 17, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
|
||||
text: R`Расположите числа $\sqrt[12]{80}$; $\sqrt[3]{3}$; $\sqrt[4]{4}$ в порядке возрастания.`,
|
||||
opts: mc('$\sqrt[4]{4};\ \sqrt[3]{3};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[4]{4};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[12]{80};\ \sqrt[4]{4}$', '$\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$', '$\sqrt[12]{80};\ \sqrt[3]{3};\ \sqrt[4]{4}$'),
|
||||
answer: 'г',
|
||||
sol: R`Возведём в $12$-ю степень: $\left(\sqrt[12]{80}\right)^{12}=80$, $\left(\sqrt[3]{3}\right)^{12}=81$, $\left(\sqrt[4]{4}\right)^{12}=64$. Так как $64<80<81$, порядок: $\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$.` },
|
||||
|
||||
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
|
||||
text: R`Найдите наименьший положительный корень уравнения $4\sin^{2}x+12\cos x-9=0$.`,
|
||||
opts: mc('$\dfrac{2\pi}{3}$', '$\arccos\dfrac52$', '$\dfrac{\pi}{3}$', '$\dfrac{\pi}{6}$', '$\pi-\arccos\dfrac52$'),
|
||||
answer: 'в',
|
||||
sol: R`$4(1-\cos^{2}x)+12\cos x-9=0$, то есть $4\cos^{2}x-12\cos x+5=0$, $\cos x=\dfrac12$ (второй корень $\dfrac52$ невозможен). Наименьший положительный корень $\dfrac{\pi}{3}$.` },
|
||||
|
||||
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
|
||||
{ idx: 19, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 3,
|
||||
text: R`Найдите произведение корней уравнения $\dfrac{3}{x+1}+1=\dfrac{10}{x^{2}+2x+1}$.`,
|
||||
answer: '-6',
|
||||
sol: R`Пусть $u=x+1$: $\dfrac3u+1=\dfrac{10}{u^{2}}$, $u^{2}+3u-10=0$, $u=2$ или $u=-5$. Тогда $x=1$ или $x=-6$, произведение $-6$.` },
|
||||
|
||||
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
|
||||
text: R`Диагонали трапеции равны $15$ и $20$. Найдите площадь трапеции, если её средняя линия равна $12{,}5$.`,
|
||||
answer: '150',
|
||||
sol: R`Площадь трапеции равна площади треугольника со сторонами, равными диагоналям ($15$ и $20$), и основанием, равным сумме оснований $=2\cdot12{,}5=25$. Так как $15^{2}+20^{2}=25^{2}$, треугольник прямоугольный: площадь $=\tfrac12\cdot15\cdot20=150$.` },
|
||||
|
||||
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
|
||||
text: R`Найдите сумму корней (или корень, если он один) уравнения $2\cdot6^{\log_7 x}=108-x^{\log_7 6}$.`,
|
||||
answer: '49',
|
||||
sol: R`Так как $x^{\log_7 6}=6^{\log_7 x}$, обозначим $t=6^{\log_7 x}$: $2t=108-t$, $t=36=6^{2}$. Тогда $\log_7 x=2$, $x=49$ — единственный корень.` },
|
||||
|
||||
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
|
||||
text: R`Найдите сумму целых решений неравенства $2^{3x+4}-10\cdot4^{x}+2^{x}\le0$.`,
|
||||
answer: '-6',
|
||||
sol: R`Пусть $u=2^{x}>0$: $16u^{3}-10u^{2}+u\le0$, $u(16u^{2}-10u+1)\le0$, $\dfrac18\le u\le\dfrac12$. Значит $-3\le x\le-1$; сумма целых $-3-2-1=-6$.` },
|
||||
|
||||
{ idx: 23, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
|
||||
text: R`По двум перпендикулярным прямым, которые пересекаются в точке $O$, движутся две точки $M_1$ и $M_2$ по направлению к точке $O$ со скоростями $1$ м/с и $2$ м/с соответственно. Достигнув точки $O$, они продолжают своё движение. В первоначальный момент времени $M_1O=5$ м, $M_2O=20$ м. Через сколько секунд расстояние между точками $M_1$ и $M_2$ будет минимальным?`,
|
||||
answer: '9',
|
||||
sol: R`Расстояние: $d^{2}=(5-t)^{2}+(20-2t)^{2}=5t^{2}-90t+425$. Минимум при $t=\dfrac{90}{10}=9$ с.` },
|
||||
|
||||
{ idx: 24, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 4,
|
||||
text: R`Парабола $y=x^{2}-6x+9$ и горизонтальная прямая $y=1{,}25$ пересекаются в точках с абсциссами $x_1$ и $x_2$. Найдите значение выражения $4x_1\cdot x_2$.`,
|
||||
answer: '31',
|
||||
sol: R`$x^{2}-6x+9=1{,}25$, то есть $x^{2}-6x+7{,}75=0$. По теореме Виета $x_1 x_2=7{,}75$, поэтому $4x_1 x_2=31$.` },
|
||||
|
||||
{ idx: 25, type: 'open', topic: 'planimetry', subtopic: 'plan-circle', diff: 4,
|
||||
text: R`Четырёхугольник $ABCD$ вписан в окружность. Если $\angle BAC=40^\circ$ и $\angle ABD=75^\circ$, то градусная мера угла между прямыми $AB$ и $CD$ равна … .`,
|
||||
answer: '35',
|
||||
sol: R`$\angle BAC=40^\circ$ опирается на дугу $BC=80^\circ$, $\angle ABD=75^\circ$ — на дугу $AD=150^\circ$. Угол между прямыми $AB$ и $CD$ равен полуразности дуг: $\dfrac{150^\circ-80^\circ}{2}=35^\circ$.` },
|
||||
|
||||
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
|
||||
text: R`Найдите значение выражения $\dfrac{\sin^{2}184^\circ}{4\sin^{2}23^\circ\cdot\sin^{2}2^\circ\cdot\sin^{2}44^\circ\cdot\sin^{2}67^\circ}$.`,
|
||||
answer: '16',
|
||||
sol: R`$\sin67^\circ=\cos23^\circ$ и $\sin46^\circ=\cos44^\circ$ дают знаменатель $=\dfrac1{16}\sin^{2}4^\circ$. Числитель $\sin^{2}184^\circ=\sin^{2}4^\circ$. Отношение $=16$.` },
|
||||
|
||||
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
|
||||
text: R`В арифметической прогрессии $130$ членов, их сумма равна $130$, а сумма членов с чётными номерами на $130$ больше суммы членов с нечётными номерами. Найдите сотый член этой прогрессии.`,
|
||||
answer: '70',
|
||||
sol: R`Сумма чётных членов равна $130$, нечётных — $0$. Разность сумм $=65d=130$, поэтому $d=2$. Из общей суммы $a_1=-128$, тогда $a_{100}=a_1+99d=-128+198=70$.` },
|
||||
|
||||
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
|
||||
text: R`В равнобокой трапеции бóльшее основание вдвое больше каждой из остальных сторон и лежит в плоскости $\alpha$. Боковая сторона образует с плоскостью $\alpha$ угол, синус которого равен $\dfrac{5\sqrt3}{18}$. Найдите $36\sin\beta$, где $\beta$ — угол между диагональю трапеции и плоскостью $\alpha$.`,
|
||||
answer: '10',
|
||||
sol: R`Пусть боковая сторона $=b$, тогда основания $b$ и $2b$, высота трапеции $\dfrac{b\sqrt3}{2}$. Из условия $\dfrac{\sqrt3}{2}\sin\theta=\dfrac{5\sqrt3}{18}$ получаем $\sin\theta=\dfrac59$. Длина диагонали $=b\sqrt3$, и $\sin\beta=\dfrac{\sin\theta}{2}=\dfrac{5}{18}$. Значит $36\sin\beta=10$.` },
|
||||
|
||||
{ idx: 29, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 5,
|
||||
text: R`Количество целых решений неравенства $2^{x+6}+\log_{0{,}5}(6-x)>13$ равно … .`,
|
||||
answer: '7',
|
||||
sol: R`ОДЗ: $x<6$. При $x=-2$ левая часть равна ровно $13$ (не годится), при $x\le-3$ меньше $13$, а при $-1\le x\le5$ — больше $13$. Целые решения: $-1,0,1,2,3,4,5$ — всего $7$.` },
|
||||
|
||||
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
|
||||
text: R`Основанием пирамиды $SABCD$ является ромб со стороной $2\sqrt3$ и углом $BAD$, равным $\arccos\dfrac34$. Ребро $SD$ перпендикулярно основанию, а ребро $SB$ образует с основанием угол $60^\circ$. Найдите радиус $R$ сферы, проходящей через точки $A$, $B$, $C$ и середину ребра $SB$. В ответ запишите $R^{2}$.`,
|
||||
answer: '26',
|
||||
sol: R`Диагональ $BD=\sqrt{2\cdot12\left(1-\tfrac34\right)}=\sqrt6$, $SD=BD\cdot\operatorname{tg}60^\circ=3\sqrt2$. В координатах с центром ромба: $A(0;\tfrac{\sqrt{42}}2;0)$, $B(\tfrac{\sqrt6}2;0;0)$, $C(0;-\tfrac{\sqrt{42}}2;0)$, середина $SB$ $=(0;0;\tfrac{3\sqrt2}2)$. Центр сферы $\left(-\tfrac{3\sqrt6}2;0;-\sqrt2\right)$, $R^{2}=\tfrac{54}{4}+\tfrac{42}{4}+2=26$.` },
|
||||
];
|
||||
|
||||
/* ── Сборка solution_html ────────────────────────────────────────────────── */
|
||||
function ansShowOf(t) {
|
||||
if (t.ansShow != null) return t.ansShow;
|
||||
if (t.type === 'mc') return `${t.answer})`;
|
||||
return `$${t.answer}$`;
|
||||
}
|
||||
function buildSolution(t) {
|
||||
const ans = ansShowOf(t);
|
||||
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
|
||||
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
|
||||
const EPS = 1e-6;
|
||||
function srvToNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||||
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
|
||||
const n = Number(t); return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
function checkAnswerServer(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
const c = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
|
||||
if (/^[^;]+;[^;]+$/.test(c)) return false;
|
||||
const cn = srvToNumber(c), un = srvToNumber(userInput);
|
||||
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
|
||||
return Math.abs(cn - un) < EPS;
|
||||
}
|
||||
|
||||
/* ── Валидация набора ──────────────────────────────────────────────────────── */
|
||||
const problems = [];
|
||||
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
|
||||
const seen = new Set();
|
||||
for (const t of TASKS) {
|
||||
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
|
||||
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
|
||||
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
|
||||
if (t.type === 'mc') {
|
||||
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
|
||||
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
|
||||
}
|
||||
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
|
||||
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
|
||||
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
|
||||
if (/−/.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
|
||||
}
|
||||
|
||||
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
|
||||
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
|
||||
if (require.main !== module) return;
|
||||
|
||||
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
|
||||
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
|
||||
|
||||
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
|
||||
console.log(`\n=== seed_ctmath_ct2011_v1 (${PROV}) variant=${VARIANT} ===`);
|
||||
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
|
||||
|
||||
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
|
||||
console.log('Типы:', JSON.stringify(byType), '\n');
|
||||
|
||||
console.log('idx | type | subtopic | d | answer');
|
||||
console.log('----+------+-----------------------+---+----------');
|
||||
for (const t of TASKS) {
|
||||
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
|
||||
problems.forEach(p => console.error(' - ' + p));
|
||||
console.error('\nЗапись отменена из-за ошибок валидации.');
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
|
||||
|
||||
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2011_v1.js --apply\n');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO exam_tasks
|
||||
(exam_key, variant, task_idx, task_type, text_html, figure_html,
|
||||
opts_json, answer, solution_html, topic, subtopic, difficulty)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
|
||||
task_type = excluded.task_type,
|
||||
text_html = excluded.text_html,
|
||||
figure_html = excluded.figure_html,
|
||||
opts_json = excluded.opts_json,
|
||||
answer = excluded.answer,
|
||||
solution_html = excluded.solution_html,
|
||||
topic = excluded.topic,
|
||||
subtopic = excluded.subtopic,
|
||||
difficulty = excluded.difficulty
|
||||
`);
|
||||
|
||||
let n = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const t of TASKS) {
|
||||
upsert.run(
|
||||
EXAM, VARIANT, t.idx, t.type,
|
||||
t.text,
|
||||
t.fig || null,
|
||||
t.type === 'mc' ? JSON.stringify(t.opts) : null,
|
||||
t.answer,
|
||||
buildSolution(t),
|
||||
t.topic, t.subtopic, t.diff
|
||||
);
|
||||
n++;
|
||||
}
|
||||
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
|
||||
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
|
||||
db.exec('COMMIT');
|
||||
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
|
||||
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
|
||||
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2011».\n`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,348 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
seed_ctmath_ct2012_v1.js
|
||||
Чистый вариант-пробник для трека exam-prep `ctmath`.
|
||||
|
||||
Источник: Централизованное тестирование (ЦТ) по математике, 2012, Вариант 1.
|
||||
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
|
||||
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2012\ЦТ 2012.pdf
|
||||
(ответы — отдельный файл «Ответы 2012.pdf», столбец «Вариант 1»).
|
||||
|
||||
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
|
||||
совпадение, включая B7=9, B10=84, B11=90, B12=-180. variant=120. Прогнан через
|
||||
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
|
||||
|
||||
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
|
||||
• А1 (равнобедренный треугольник) → пары углов даны числами (70°,40° → равнобедренный, №3);
|
||||
• А13 (прямая/плоскость/двугранный угол) → все данные в тексте (площадь 14√3);
|
||||
• B6 (середины сторон прямоугольника) → расположение M,N,P,Q задано в тексте (площадь 4).
|
||||
А15 уточнена по таблице: радикал $\sqrt{5^{5}\cdot20}=250$, знаменатель $\sqrt[4]{10}$ → $25\sqrt[4]{10}$.
|
||||
Без авторских ссылок (политика «все учебники наши»).
|
||||
|
||||
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
|
||||
Запуск:
|
||||
node backend/scripts/seed_ctmath_ct2012_v1.js # DRY-RUN (по умолчанию)
|
||||
node backend/scripts/seed_ctmath_ct2012_v1.js --apply # запись в БД
|
||||
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
const VARIANT = 120;
|
||||
const N_TASKS = 30;
|
||||
const PROV = 'ЦТ–2012, Вариант 1';
|
||||
const R = String.raw;
|
||||
|
||||
const L = ['а', 'б', 'в', 'г', 'д'];
|
||||
const mc = (...html) => html.map((h, i) => [L[i], h]);
|
||||
|
||||
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
|
||||
const TASKS = [
|
||||
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
|
||||
{ idx: 1, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
|
||||
text: R`У каждого из пяти треугольников на рисунке известны два угла. Укажите номер треугольника, который является равнобедренным: $1)\ 55^\circ$ и $40^\circ$; $\ 2)\ 60^\circ$ и $40^\circ$; $\ 3)\ 70^\circ$ и $40^\circ$; $\ 4)\ 65^\circ$ и $40^\circ$; $\ 5)\ 75^\circ$ и $40^\circ$.`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'в',
|
||||
sol: R`Третий угол равен $180^\circ$ минус два данных. Для пары $70^\circ$ и $40^\circ$ третий угол $=70^\circ$, появляются два равных угла — треугольник равнобедренный (№3).` },
|
||||
|
||||
{ idx: 2, type: 'mc', topic: 'expressions', subtopic: 'expr-logarithms', diff: 2,
|
||||
text: R`Укажите верное равенство:<br>$1)\ 3^{\log_3 3}=5$; $\ 2)\ \log_7 7=7$; $\ 3)\ \log_{31}\dfrac{1}{31}=-1$; $\ 4)\ \log_5 25=5$; $\ 5)\ \log_{23} 23=0$.`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'в',
|
||||
sol: R`$\log_{31}\dfrac{1}{31}=\log_{31}31^{-1}=-1$ — верно (равенство 3). Остальные ложны: $3^{\log_3 3}=3$, $\log_7 7=1$, $\log_5 25=2$, $\log_{23}23=1$.` },
|
||||
|
||||
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
|
||||
text: R`Сумма всех натуральных делителей числа $28$ равна:`,
|
||||
opts: mc('$55$', '$11$', '$9$', '$27$', '$56$'),
|
||||
answer: 'д',
|
||||
sol: R`Делители $28$: $1,2,4,7,14,28$. Их сумма $=56$.` },
|
||||
|
||||
{ idx: 4, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
|
||||
text: R`Даны квадратные уравнения: $1)\ 4x^{2}-3x-3=0$; $\ 2)\ 5x^{2}+20x+20=0$; $\ 3)\ 2x^{2}+3x+12=0$; $\ 4)\ 7x^{2}-4x-5=0$; $\ 5)\ 4x^{2}+8x+4=0$. Укажите уравнение, которое не имеет корней.`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'в',
|
||||
sol: R`Корней нет при $D<0$. Для $2x^{2}+3x+12=0$: $D=9-96=-87<0$ (№3). У остальных $D\ge0$.` },
|
||||
|
||||
{ idx: 5, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
|
||||
text: R`Если $10^{2}\cdot\alpha=741{,}63287$, то значение $\alpha$ с точностью до сотых равно:`,
|
||||
opts: mc('$74{,}16$', '$7{,}42$', '$7{,}41$', '$74\,163{,}29$', '$7416{,}33$'),
|
||||
answer: 'б',
|
||||
sol: R`$\alpha=\dfrac{741{,}63287}{100}=7{,}4163287\approx7{,}42$.` },
|
||||
|
||||
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 2,
|
||||
text: R`Число $133$ является членом арифметической прогрессии $4,\ 7,\ 10,\ 13,\ \ldots$ Укажите его номер.`,
|
||||
opts: mc('$44$', '$42$', '$40$', '$46$', '$48$'),
|
||||
answer: 'а',
|
||||
sol: R`$a_n=4+3(n-1)=3n+1$. Из $3n+1=133$ получаем $n=44$.` },
|
||||
|
||||
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-modulus', diff: 2,
|
||||
text: R`Решите неравенство $|-x|\ge5$.`,
|
||||
opts: mc('$x\in[5;+\infty)$', '$x\in(-\infty;-5]$', '$x\in[-5;5]$', '$x\in(-\infty;-5]\cup[5;+\infty)$', '$x_1=-5,\ x_2=5$'),
|
||||
answer: 'г',
|
||||
sol: R`$|-x|=|x|\ge5$ равносильно $x\le-5$ или $x\ge5$, то есть $x\in(-\infty;-5]\cup[5;+\infty)$.` },
|
||||
|
||||
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
|
||||
text: R`Вычислите $\dfrac{3{,}2+0{,}8:\left(\tfrac16+\tfrac13\right)}{0{,}1}$.`,
|
||||
opts: mc('$48$', '$0{,}48$', '$4{,}8$', '$80$', '$0{,}8$'),
|
||||
answer: 'а',
|
||||
sol: R`$\tfrac16+\tfrac13=\tfrac12$, $0{,}8:\tfrac12=1{,}6$, числитель $=3{,}2+1{,}6=4{,}8$. Делим на $0{,}1$: $48$.` },
|
||||
|
||||
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-circles', diff: 1,
|
||||
text: R`Площадь круга равна $81\pi$. Диаметр этого круга равен:`,
|
||||
opts: mc('$18$', '$18\pi$', '$9$', '$9\pi$', '$81$'),
|
||||
answer: 'а',
|
||||
sol: R`$\pi r^{2}=81\pi$, $r=9$, диаметр $=18$.` },
|
||||
|
||||
{ idx: 10, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 2,
|
||||
text: R`Найдите наименьший положительный корень уравнения $\sin2x=\dfrac12$.`,
|
||||
opts: mc('$\dfrac{\pi}{6}$', '$\dfrac{\pi}{12}$', '$\dfrac{\pi}{3}$', '$\dfrac{5\pi}{12}$', '$\dfrac{\pi}{8}$'),
|
||||
answer: 'б',
|
||||
sol: R`$2x=\dfrac{\pi}{6}+2\pi k$ или $2x=\dfrac{5\pi}{6}+2\pi k$, поэтому $x=\dfrac{\pi}{12}+\pi k$ или $x=\dfrac{5\pi}{12}+\pi k$. Наименьший положительный — $\dfrac{\pi}{12}$.` },
|
||||
|
||||
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
|
||||
text: R`Четырёхугольник $MNPK$, в котором $\angle N=128^\circ$, вписан в окружность. Найдите градусную меру угла $K$.`,
|
||||
opts: mc('$64^\circ$', '$128^\circ$', '$100^\circ$', '$180^\circ$', '$52^\circ$'),
|
||||
answer: 'д',
|
||||
sol: R`У вписанного четырёхугольника суммы противоположных углов равны $180^\circ$. Углы $N$ и $K$ противоположны, поэтому $\angle K=180^\circ-128^\circ=52^\circ$.` },
|
||||
|
||||
{ idx: 12, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
|
||||
text: R`На одной чаше уравновешенных весов лежат $3$ яблока и $1$ груша, на другой — $2$ яблока, $2$ груши и гирька весом $20$ г. Каков вес одного яблока (в граммах), если все фрукты вместе весят $780$ г? Считайте все яблоки одинаковыми по весу и все груши одинаковыми по весу.`,
|
||||
opts: mc('$95$', '$105$', '$100$', '$125$', '$115$'),
|
||||
answer: 'б',
|
||||
sol: R`Равновесие: $3a+p=2a+2p+20$, то есть $a-p=20$. Все фрукты: $5a+3p=780$. Отсюда $a=105$, $p=85$.` },
|
||||
|
||||
{ idx: 13, type: 'mc', topic: 'stereometry', subtopic: 'ster-lines-planes', diff: 3,
|
||||
text: R`Прямая $a$, параллельная плоскости $\alpha$, находится от неё на расстоянии $6$. Через прямую $a$ проведена плоскость $\beta$, пересекающая плоскость $\alpha$ по прямой $b$ и образующая с ней угол $60^\circ$. Найдите площадь четырёхугольника $ABCD$, если $A$ и $B$ — точки прямой $a$, причём $AB=4$, а $C$ и $D$ — такие точки прямой $b$, что $CD=3$.`,
|
||||
opts: mc('$42$', '$42\sqrt3$', '$\dfrac{21\sqrt3}{2}$', '$10{,}5$', '$14\sqrt3$'),
|
||||
answer: 'д',
|
||||
sol: R`Прямые $a$ и $b$ параллельны, поэтому $ABCD$ — трапеция с основаниями $AB=4$ и $CD=3$. Её высота (расстояние между $a$ и $b$ в плоскости $\beta$) равна $\dfrac{6}{\sin60^\circ}=4\sqrt3$. Площадь $=\dfrac{4+3}{2}\cdot4\sqrt3=14\sqrt3$.` },
|
||||
|
||||
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
|
||||
text: R`Упростите выражение $\dfrac{125^{x}+25^{x}-12\cdot5^{x}}{5^{x}\left(5^{x}-3\right)}$.`,
|
||||
opts: mc('$5^{x}$', '$125^{x}-4$', '$5^{x}+4$', '$5^{x}-4$', '$2\cdot5^{x}$'),
|
||||
answer: 'в',
|
||||
sol: R`Пусть $u=5^{x}$. Числитель $=u^{3}+u^{2}-12u=u(u+4)(u-3)$, знаменатель $=u(u-3)$. Дробь $=u+4=5^{x}+4$.` },
|
||||
|
||||
{ idx: 15, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
|
||||
text: R`Корень уравнения $\sqrt{10}\cdot x=\dfrac{\sqrt{5^{5}\cdot20}}{\sqrt[4]{10}}$ равен:`,
|
||||
opts: mc('$25\sqrt[4]{10}$', '$50\sqrt2$', '$25\sqrt[5]{50}$', '$4\sqrt[3]{20}$', '$10\sqrt{10}$'),
|
||||
answer: 'а',
|
||||
sol: R`$\sqrt{5^{5}\cdot20}=\sqrt{5^{6}\cdot4}=5^{3}\cdot2=250$, поэтому $x=\dfrac{250}{\sqrt{10}\cdot\sqrt[4]{10}}=\dfrac{250}{10^{3/4}}=25\cdot10^{1/4}=25\sqrt[4]{10}$.` },
|
||||
|
||||
{ idx: 16, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
|
||||
text: R`Какая из прямых $1)\ y=-3$; $\ 2)\ y=-1{,}5$; $\ 3)\ y=0$; $\ 4)\ y=4{,}3$; $\ 5)\ y=2$ пересекает график функции $y=\dfrac14 x^{2}-3x+11$ в двух точках?`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'г',
|
||||
sol: R`Вершина параболы: $x=6$, $y_{\min}=\dfrac14\cdot36-18+11=2$, ветви вверх. Прямая $y=c$ пересекает график в двух точках при $c>2$. Это $y=4{,}3$ (№4).` },
|
||||
|
||||
{ idx: 17, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
|
||||
text: R`Если $\dfrac{5x}{y}=\dfrac12$, то значение выражения $\dfrac{3y+9x}{13x-y}$ равно:`,
|
||||
opts: mc('$12$', '$13$', '$\dfrac{11}{7}$', '$\dfrac{93}{129}$', '$\dfrac{1}{13}$'),
|
||||
answer: 'б',
|
||||
sol: R`Из $\dfrac{5x}{y}=\dfrac12$ следует $y=10x$. Тогда $\dfrac{3\cdot10x+9x}{13x-10x}=\dfrac{39x}{3x}=13$.` },
|
||||
|
||||
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
|
||||
text: R`Наименьшее целое решение неравенства $\lg(x^{2}-2x-8)-\lg(x+2)\le\lg4$ равно:`,
|
||||
opts: mc('$1$', '$-2$', '$4$', '$5$', '$8$'),
|
||||
answer: 'г',
|
||||
sol: R`ОДЗ: $x>4$. На нём $\dfrac{x^{2}-2x-8}{x+2}=x-4$, и неравенство $\lg(x-4)\le\lg4$ даёт $x\le8$. Итого $4<x\le8$; наименьшее целое — $5$.` },
|
||||
|
||||
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
|
||||
{ idx: 19, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
|
||||
text: R`Если в правильной четырёхугольной пирамиде высота равна $4$, а площадь диагонального сечения равна $12$, то её объём равен … .`,
|
||||
answer: '24',
|
||||
sol: R`Диагональное сечение — треугольник с основанием $d$ (диагональ квадрата) и высотой $4$: $\tfrac12 d\cdot4=12$, $d=6$. Сторона основания $a=\dfrac{d}{\sqrt2}=3\sqrt2$, площадь основания $=18$. Объём $=\tfrac13\cdot18\cdot4=24$.` },
|
||||
|
||||
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 2,
|
||||
text: R`Найдите количество всех целых решений неравенства $\dfrac{64x-x^{3}}{5x}>0$.`,
|
||||
answer: '14',
|
||||
sol: R`При $x\ne0$ неравенство равносильно $\dfrac{64-x^{2}}{5}>0$, то есть $-8<x<8$. Целые (без $0$): от $-7$ до $7$ — это $14$ чисел.` },
|
||||
|
||||
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 3,
|
||||
text: R`Точки $A(1;2)$, $B(5;6)$ и $C(8;6)$ — вершины трапеции $ABCD$ ($AD\parallel BC$). Найдите сумму координат точки $D$, если $BD=4\sqrt2$.`,
|
||||
answer: '11',
|
||||
sol: R`$BC$ горизонтальна, значит $AD$ тоже горизонтальна и $D$ имеет ординату $2$. Из $BD^{2}=(d-5)^{2}+16=32$ получаем $d=9$ ($d=1$ даёт $D=A$). Тогда $D(9;2)$, сумма координат $11$.` },
|
||||
|
||||
{ idx: 22, type: 'open', topic: 'planimetry', subtopic: 'plan-polygons', diff: 3,
|
||||
text: R`Найдите периметр правильного шестиугольника, меньшая диагональ которого равна $10\sqrt3$.`,
|
||||
answer: '60',
|
||||
sol: R`У правильного шестиугольника меньшая диагональ равна $a\sqrt3$, поэтому $a\sqrt3=10\sqrt3$, $a=10$. Периметр $=6a=60$.` },
|
||||
|
||||
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
|
||||
text: R`Найдите произведение корней уравнения $4^{x^{2}}+128=3^{1-x^{2}}\cdot12^{x^{2}}$.`,
|
||||
answer: '-3',
|
||||
sol: R`Пусть $u=x^{2}$. Так как $3^{1-u}\cdot12^{u}=3\cdot4^{u}$, уравнение даёт $4^{u}+128=3\cdot4^{u}$, $4^{u}=64$, $u=3$. Тогда $x=\pm\sqrt3$, произведение корней $-3$.` },
|
||||
|
||||
{ idx: 24, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 5,
|
||||
text: R`Площадь прямоугольника $ABCD$ равна $20$. Точки $M$, $N$, $P$, $Q$ — середины его сторон $AB$, $BC$, $CD$, $DA$ соответственно. Найдите площадь четырёхугольника, заключённого между прямыми $AN$, $BP$, $CQ$ и $DM$.`,
|
||||
answer: '4',
|
||||
sol: R`Прямые $AN\parallel CQ$ и $BP\parallel DM$, поэтому внутренний четырёхугольник — параллелограмм. Координатный расчёт показывает, что его площадь составляет $\dfrac15$ площади прямоугольника: $\dfrac{20}{5}=4$.` },
|
||||
|
||||
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
|
||||
text: R`Решите уравнение $x^{2}-7x+10=\dfrac{7}{x^{2}-11x+28}$ и найдите сумму его корней.`,
|
||||
answer: '9',
|
||||
sol: R`Уравнение приводится к $(x^{2}-9x+21)(x^{2}-9x+13)=0$. Первый множитель действительных корней не имеет ($D<0$), второй даёт корни с суммой $9$.` },
|
||||
|
||||
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
|
||||
text: R`Найдите значение выражения $16\sin\left(\alpha-\dfrac{\pi}{4}\right)$, если $\sin2\alpha=\dfrac{23}{32}$ и $2\alpha\in\left(0;\dfrac{\pi}{2}\right)$.`,
|
||||
answer: '-6',
|
||||
sol: R`$16\sin\left(\alpha-\tfrac{\pi}{4}\right)=8\sqrt2(\sin\alpha-\cos\alpha)$. Так как $(\sin\alpha-\cos\alpha)^{2}=1-\sin2\alpha=\tfrac{9}{32}$ и при $\alpha<\tfrac{\pi}{4}$ разность отрицательна, $\sin\alpha-\cos\alpha=-\tfrac{3\sqrt2}{8}$. Значение $=8\sqrt2\cdot\left(-\tfrac{3\sqrt2}{8}\right)=-6$.` },
|
||||
|
||||
{ idx: 27, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
|
||||
text: R`Найдите сумму целых значений $x$, принадлежащих области определения функции $y=\log_{2-x}\left(12-x-x^{2}\right)$.`,
|
||||
answer: '-6',
|
||||
sol: R`Условия: $2-x>0$, $2-x\ne1$ и $12-x-x^{2}>0$. Получаем $-4<x<2$, $x\ne1$. Целые: $-3,-2,-1,0$; их сумма $-6$.` },
|
||||
|
||||
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
|
||||
text: R`Прямоугольный треугольник с катетами, равными $6$ и $2\sqrt7$, вращается вокруг оси, содержащей его гипотенузу. Найдите значение выражения $\dfrac{2V}{\pi}$, где $V$ — объём фигуры вращения.`,
|
||||
answer: '84',
|
||||
sol: R`Гипотенуза $=\sqrt{36+28}=8$, высота к ней $h=\dfrac{6\cdot2\sqrt7}{8}=\dfrac{3\sqrt7}{2}$. Фигура — два конуса с общим основанием: $V=\tfrac13\pi h^{2}\cdot8=\tfrac13\pi\cdot\tfrac{63}{4}\cdot8=42\pi$. Тогда $\dfrac{2V}{\pi}=84$.` },
|
||||
|
||||
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 5,
|
||||
text: R`Из двух растворов с различным процентным содержанием спирта массой $100$ г и $900$ г отлили по одинаковому количеству. Каждый из отлитых растворов долили в остаток другого раствора, после чего процентное содержание спирта в обоих растворах стало одинаковым. Найдите, сколько раствора (в граммах) было отлито из каждого раствора.`,
|
||||
answer: '90',
|
||||
sol: R`Пусть отлито по $m$ г. Равенство итоговых концентраций приводит к $(900-10m)(c_1-c_2)=0$. Поскольку концентрации различны, $900-10m=0$, $m=90$.` },
|
||||
|
||||
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 5,
|
||||
text: R`Найдите произведение корней уравнения $x-\sqrt{x^{2}-36}=\dfrac{(x-6)^{2}}{2x+12}$.`,
|
||||
answer: '-180',
|
||||
sol: R`ОДЗ: $|x|\ge6$. После преобразований и возведения в квадрат получаем $x^{4}-168x^{2}-2160=0$, откуда $x^{2}=180$, то есть $x=\pm6\sqrt5$ (оба корня подходят). Произведение $=-180$.` },
|
||||
];
|
||||
|
||||
/* ── Сборка solution_html ────────────────────────────────────────────────── */
|
||||
function ansShowOf(t) {
|
||||
if (t.ansShow != null) return t.ansShow;
|
||||
if (t.type === 'mc') return `${t.answer})`;
|
||||
return `$${t.answer}$`;
|
||||
}
|
||||
function buildSolution(t) {
|
||||
const ans = ansShowOf(t);
|
||||
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
|
||||
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
|
||||
const EPS = 1e-6;
|
||||
function srvToNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||||
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
|
||||
const n = Number(t); return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
function checkAnswerServer(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
const c = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
|
||||
if (/^[^;]+;[^;]+$/.test(c)) return false;
|
||||
const cn = srvToNumber(c), un = srvToNumber(userInput);
|
||||
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
|
||||
return Math.abs(cn - un) < EPS;
|
||||
}
|
||||
|
||||
/* ── Валидация набора ──────────────────────────────────────────────────────── */
|
||||
const problems = [];
|
||||
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
|
||||
const seen = new Set();
|
||||
for (const t of TASKS) {
|
||||
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
|
||||
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
|
||||
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
|
||||
if (t.type === 'mc') {
|
||||
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
|
||||
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
|
||||
}
|
||||
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
|
||||
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
|
||||
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
|
||||
if (/−/.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
|
||||
}
|
||||
|
||||
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
|
||||
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
|
||||
if (require.main !== module) return;
|
||||
|
||||
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
|
||||
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
|
||||
|
||||
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
|
||||
console.log(`\n=== seed_ctmath_ct2012_v1 (${PROV}) variant=${VARIANT} ===`);
|
||||
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
|
||||
|
||||
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
|
||||
console.log('Типы:', JSON.stringify(byType), '\n');
|
||||
|
||||
console.log('idx | type | subtopic | d | answer');
|
||||
console.log('----+------+-----------------------+---+----------');
|
||||
for (const t of TASKS) {
|
||||
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
|
||||
problems.forEach(p => console.error(' - ' + p));
|
||||
console.error('\nЗапись отменена из-за ошибок валидации.');
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
|
||||
|
||||
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2012_v1.js --apply\n');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO exam_tasks
|
||||
(exam_key, variant, task_idx, task_type, text_html, figure_html,
|
||||
opts_json, answer, solution_html, topic, subtopic, difficulty)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
|
||||
task_type = excluded.task_type,
|
||||
text_html = excluded.text_html,
|
||||
figure_html = excluded.figure_html,
|
||||
opts_json = excluded.opts_json,
|
||||
answer = excluded.answer,
|
||||
solution_html = excluded.solution_html,
|
||||
topic = excluded.topic,
|
||||
subtopic = excluded.subtopic,
|
||||
difficulty = excluded.difficulty
|
||||
`);
|
||||
|
||||
let n = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const t of TASKS) {
|
||||
upsert.run(
|
||||
EXAM, VARIANT, t.idx, t.type,
|
||||
t.text,
|
||||
t.fig || null,
|
||||
t.type === 'mc' ? JSON.stringify(t.opts) : null,
|
||||
t.answer,
|
||||
buildSolution(t),
|
||||
t.topic, t.subtopic, t.diff
|
||||
);
|
||||
n++;
|
||||
}
|
||||
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
|
||||
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
|
||||
db.exec('COMMIT');
|
||||
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
|
||||
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
|
||||
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2012».\n`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,348 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
seed_ctmath_ct2013_v1.js
|
||||
Чистый вариант-пробник для трека exam-prep `ctmath`.
|
||||
|
||||
Источник: Централизованное тестирование (ЦТ) по математике, 2013, Вариант 1.
|
||||
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
|
||||
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2013\ЦТ2013.pdf
|
||||
(ответы — отдельный файл «Ответы ЦТ 2013.pdf», столбец «Вариант 1»).
|
||||
|
||||
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
|
||||
совпадение, включая B3=75, B9=40, B10=6, B12=-5. variant=119. Прогнан через
|
||||
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
|
||||
|
||||
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
|
||||
• А2 (образующая цилиндра) → взаимное расположение точек дано в тексте (AD ⟂ основаниям → AD);
|
||||
• А3 (точка на графике) → прямая задана как $y=13$, точки перечислены (T(-7;13));
|
||||
• А6 (углы при развёрнутом угле) → порядок лучей задан явно (∠BOC=40°);
|
||||
• А16 (сечение параллелепипеда) → размеры/угол 60° в тексте (сечение 12×6=72).
|
||||
Без авторских ссылок (политика «все учебники наши»).
|
||||
|
||||
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
|
||||
Запуск:
|
||||
node backend/scripts/seed_ctmath_ct2013_v1.js # DRY-RUN (по умолчанию)
|
||||
node backend/scripts/seed_ctmath_ct2013_v1.js --apply # запись в БД
|
||||
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
const VARIANT = 119;
|
||||
const N_TASKS = 30;
|
||||
const PROV = 'ЦТ–2013, Вариант 1';
|
||||
const R = String.raw;
|
||||
|
||||
const L = ['а', 'б', 'в', 'г', 'д'];
|
||||
const mc = (...html) => html.map((h, i) => [L[i], h]);
|
||||
|
||||
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
|
||||
const TASKS = [
|
||||
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
|
||||
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
|
||||
text: R`Среди чисел $\sqrt9$; $-9$; $\dfrac19$; $-0{,}9$; $9^{-1}$ выберите число, противоположное числу $9$.`,
|
||||
opts: mc('$\sqrt9$', '$-9$', '$\dfrac19$', '$-0{,}9$', '$9^{-1}$'),
|
||||
answer: 'б',
|
||||
sol: R`Противоположное числу $9$ — это $-9$.` },
|
||||
|
||||
{ idx: 2, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 1,
|
||||
text: R`Прямой круговой цилиндр; $O$ и $O_1$ — центры верхнего и нижнего оснований. Точки $A$ и $B$ лежат на окружности верхнего основания, $C$ и $D$ — на окружности нижнего, причём $A$ находится точно над $D$ (отрезок $AD$ перпендикулярен основаниям). Образующей цилиндра является отрезок:`,
|
||||
opts: mc('$DB$', '$DC$', '$DO_1$', '$OO_1$', '$AD$'),
|
||||
answer: 'д',
|
||||
sol: R`Образующая прямого цилиндра — отрезок поверхности, перпендикулярный основаниям и соединяющий соответствующие точки окружностей. Это отрезок $AD$ ($OO_1$ — ось, а не образующая).` },
|
||||
|
||||
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 1,
|
||||
text: R`Среди точек $B(13;0)$, $T(-7;13)$, $C\left(-\sqrt{13};\sqrt{13}\right)$, $O(0;0)$, $L(0;-13)$ выберите ту, которая принадлежит графику функции $y=13$.`,
|
||||
opts: mc('$B$', '$T$', '$C$', '$O$', '$L$'),
|
||||
answer: 'б',
|
||||
sol: R`Графику $y=13$ принадлежат точки с ординатой $13$. Это $T(-7;13)$.` },
|
||||
|
||||
{ idx: 4, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
|
||||
text: R`Найдите значение выражения $\left(2\tfrac{7}{12}-2\tfrac{17}{36}\right)\cdot2{,}7-0{,}4$.`,
|
||||
opts: mc('$0{,}1$', '$-0{,}7$', '$-0{,}1$', '$0{,}3$', '$-1{,}5$'),
|
||||
answer: 'в',
|
||||
sol: R`$2\tfrac{7}{12}-2\tfrac{17}{36}=\tfrac{93-89}{36}=\tfrac{4}{36}=\tfrac19$. Тогда $\tfrac19\cdot2{,}7-0{,}4=0{,}3-0{,}4=-0{,}1$.` },
|
||||
|
||||
{ idx: 5, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
|
||||
text: R`Одно число меньше другого на $64$, что составляет $16\%$ большего числа. Найдите меньшее число.`,
|
||||
opts: mc('$800$', '$470$', '$336$', '$464$', '$390$'),
|
||||
answer: 'в',
|
||||
sol: R`Большее число $=\dfrac{64}{0{,}16}=400$, меньшее $=400-64=336$.` },
|
||||
|
||||
{ idx: 6, type: 'mc', topic: 'planimetry', subtopic: 'plan-angles', diff: 2,
|
||||
text: R`Угол $AOM$ — развёрнутый ($A$, $O$, $M$ на одной прямой). Лучи $OB$ и $OC$ проведены по одну сторону от прямой $AM$, причём луч $OB$ ближе к лучу $OA$. Известно, что $\angle AOC=107^\circ$, $\angle BOM=113^\circ$. Найдите величину угла $BOC$.`,
|
||||
opts: mc('$73^\circ$', '$67^\circ$', '$17^\circ$', '$40^\circ$', '$23^\circ$'),
|
||||
answer: 'г',
|
||||
sol: R`$\angle AOB=180^\circ-\angle BOM=67^\circ$, поэтому $\angle BOC=\angle AOC-\angle AOB=107^\circ-67^\circ=40^\circ$.` },
|
||||
|
||||
{ idx: 7, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
|
||||
text: R`Образующая конуса равна $26$ и наклонена к плоскости основания под углом $60^\circ$. Найдите площадь боковой поверхности конуса.`,
|
||||
opts: mc('$338\pi$', '$338\sqrt3\,\pi$', '$169\pi$', '$260\sqrt3\,\pi$', '$676\pi$'),
|
||||
answer: 'а',
|
||||
sol: R`Радиус $r=l\cos60^\circ=26\cdot\tfrac12=13$. Боковая поверхность $=\pi r l=\pi\cdot13\cdot26=338\pi$.` },
|
||||
|
||||
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
|
||||
text: R`Расположите числа $2{,}44$; $\dfrac{18}{7}$; $2{,}(4)$ в порядке возрастания.`,
|
||||
opts: mc('$2{,}44;\ \dfrac{18}{7};\ 2{,}(4)$', '$2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$', '$\dfrac{18}{7};\ 2{,}44;\ 2{,}(4)$', '$2{,}(4);\ \dfrac{18}{7};\ 2{,}44$', '$2{,}(4);\ 2{,}44;\ \dfrac{18}{7}$'),
|
||||
answer: 'б',
|
||||
sol: R`$2{,}44<2{,}(4)=2{,}444\ldots<\dfrac{18}{7}=2{,}571\ldots$, то есть $2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$.` },
|
||||
|
||||
{ idx: 9, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
|
||||
text: R`Одна из сторон прямоугольника на $7$ см длиннее другой, а его площадь равна $78$ см². Уравнение, одним из корней которого является длина меньшей стороны прямоугольника, имеет вид:`,
|
||||
opts: mc('$x^{2}-78x+7=0$', '$x^{2}-7x-78=0$', '$x^{2}+7x+78=0$', '$x^{2}+7x-78=0$', '$x^{2}+78x-7=0$'),
|
||||
answer: 'г',
|
||||
sol: R`Если меньшая сторона $x$, то $x(x+7)=78$, то есть $x^{2}+7x-78=0$.` },
|
||||
|
||||
{ idx: 10, type: 'mc', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 2,
|
||||
text: R`Точки $A(-3;3)$ и $B(4;1)$ — вершины квадрата $ABCD$. Периметр квадрата равен:`,
|
||||
opts: mc('$4\sqrt{17}$', '$2\sqrt{53}$', '$18$', '$15$', '$4\sqrt{53}$'),
|
||||
answer: 'д',
|
||||
sol: R`$AB=\sqrt{(4+3)^{2}+(1-3)^{2}}=\sqrt{49+4}=\sqrt{53}$ — сторона квадрата. Периметр $=4\sqrt{53}$.` },
|
||||
|
||||
{ idx: 11, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
|
||||
text: R`Упростите выражение $\dfrac{11\sqrt{11}+5\sqrt5}{\sqrt{11}+\sqrt5}-\sqrt{55}+\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}$.`,
|
||||
opts: mc('$\dfrac{1}{\sqrt{11}+\sqrt5}$', '$\sqrt{55}$', '$16$', '$26$', '$\dfrac{5}{\sqrt{11}-\sqrt5}$'),
|
||||
answer: 'г',
|
||||
sol: R`$\dfrac{(\sqrt{11})^{3}+(\sqrt5)^{3}}{\sqrt{11}+\sqrt5}=11-\sqrt{55}+5=16-\sqrt{55}$; $\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}=2\sqrt5(\sqrt{11}+\sqrt5)=2\sqrt{55}+10$. Сумма: $(16-\sqrt{55})-\sqrt{55}+(2\sqrt{55}+10)=26$.` },
|
||||
|
||||
{ idx: 12, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
|
||||
text: R`Решением неравенства $\dfrac{26}{3}-\dfrac{7x^{2}+4x}{7}>\dfrac{2-3x^{2}}{3}$ является промежуток:`,
|
||||
opts: mc('$(14;+\infty)$', '$(-14;+\infty)$', '$\left(-\infty;\dfrac{1}{14}\right)$', '$(-\infty;14)$', '$\left(\dfrac{1}{14};+\infty\right)$'),
|
||||
answer: 'г',
|
||||
sol: R`Умножив на $21$: $182-3(7x^{2}+4x)>7(2-3x^{2})$, то есть $182-21x^{2}-12x>14-21x^{2}$, $182-12x>14$, $x<14$.` },
|
||||
|
||||
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
|
||||
text: R`Найдите длину средней линии прямоугольной трапеции с острым углом $60^\circ$, у которой бóльшая боковая сторона и бóльшее основание равны $10$.`,
|
||||
opts: mc('$5\sqrt3$', '$10\sqrt3$', '$15$', '$5$', '$7{,}5$'),
|
||||
answer: 'д',
|
||||
sol: R`Проекция наклонной боковой стороны на основание $=10\cos60^\circ=5$, поэтому меньшее основание $=10-5=5$. Средняя линия $=\dfrac{10+5}{2}=7{,}5$.` },
|
||||
|
||||
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 3,
|
||||
text: R`Упростите выражение $\left(5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}\right):(a+b+5c)\cdot2ac$.`,
|
||||
opts: mc('$a+5c-b$', '$4a^{2}c^{2}$', '$5$', '$a+5c+b$', '$a-5c-b$'),
|
||||
answer: 'а',
|
||||
sol: R`$5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}=\dfrac{(a+5c)^{2}-b^{2}}{2ac}=\dfrac{(a+5c-b)(a+5c+b)}{2ac}$. После деления на $(a+b+5c)$ и умножения на $2ac$ получаем $a+5c-b$.` },
|
||||
|
||||
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
|
||||
text: R`Найдите сумму целых решений неравенства $3(x-5)>(x-5)^{2}$.`,
|
||||
opts: mc('$13$', '$9$', '$-13$', '$26$', '$-9$'),
|
||||
answer: 'а',
|
||||
sol: R`Пусть $u=x-5$: $3u>u^{2}$, $u(u-3)<0$, $0<u<3$, значит $5<x<8$. Целые $6$ и $7$, их сумма $13$.` },
|
||||
|
||||
{ idx: 16, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
|
||||
text: R`$ABCDA_1B_1C_1D_1$ — прямоугольный параллелепипед, $AB=12$, $AD=3$. Через середины рёбер $AA_1$ и $BB_1$ проведена плоскость, составляющая угол $60^\circ$ с плоскостью основания $ABCD$. Найдите площадь сечения параллелепипеда этой плоскостью.`,
|
||||
opts: mc('$72$', '$36\sqrt3$', '$36$', '$18$', '$36\sqrt2$'),
|
||||
answer: 'а',
|
||||
sol: R`Сечение — параллелограмм; одна сторона равна $AB=12$, другая проходит через всю глубину $AD=3$ под углом $60^\circ$: её длина $=\dfrac{3}{\cos60^\circ}=6$. Площадь $=12\cdot6=72$.` },
|
||||
|
||||
{ idx: 17, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
|
||||
text: R`Сумма наибольшего и наименьшего значений функции $y=(3\sin2x+3\cos2x)^{2}$ равна:`,
|
||||
opts: mc('$8$', '$9$', '$18$', '$36$', '$3$'),
|
||||
answer: 'в',
|
||||
sol: R`$y=9(\sin2x+\cos2x)^{2}=9(1+\sin4x)$. Так как $\sin4x\in[-1;1]$, то $y\in[0;18]$. Сумма $0+18=18$.` },
|
||||
|
||||
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
|
||||
text: R`Корень уравнения $\log_{1{,}6}\dfrac{9-4x}{3x-11}+\log_{1{,}6}\big((9-4x)(3x-11)\big)=0$ (или их сумма, если корней несколько) принадлежит промежутку:`,
|
||||
opts: mc('$[0;1)$', '$[1;2)$', '$(2;3]$', '$(3;4]$', '$[-1;0)$'),
|
||||
answer: 'в',
|
||||
sol: R`Сумма логарифмов равна $\log_{1{,}6}(9-4x)^{2}=0$, поэтому $(9-4x)^{2}=1$, $x=2$ или $x=2{,}5$. Из условия положительности обоих аргументов остаётся $x=2{,}5\in(2;3]$.` },
|
||||
|
||||
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
|
||||
{ idx: 19, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
|
||||
text: R`Автомобиль проехал некоторое расстояние, израсходовав $21$ л топлива; расход составил $9$ л на $100$ км пробега. Затем расход топлива вырос до $12$ л на $100$ км. Сколько литров топлива понадобится автомобилю, чтобы проехать такое же расстояние?`,
|
||||
answer: '28',
|
||||
sol: R`Расстояние $=\dfrac{21}{9}\cdot100$ км. При расходе $12$ л/$100$ км нужно $\dfrac{21}{9}\cdot12=28$ л.` },
|
||||
|
||||
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 3,
|
||||
text: R`Решите уравнение $\sqrt{x-5}-\sqrt{(x-5)(x+2)}=0$. В ответ запишите сумму его корней (корень, если он один).`,
|
||||
answer: '5',
|
||||
sol: R`ОДЗ: $x\ge5$. $\sqrt{x-5}\,\big(1-\sqrt{x+2}\big)=0$ даёт $x=5$ (второй множитель при $x\ge5$ не равен нулю). Единственный корень $5$.` },
|
||||
|
||||
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-triangles', diff: 3,
|
||||
text: R`Основание остроугольного равнобедренного треугольника равно $10$, а синус противолежащего угла равен $0{,}6$. Найдите площадь треугольника.`,
|
||||
answer: '75',
|
||||
sol: R`Острый противолежащий угол $\alpha$: $\sin\alpha=0{,}6$, $\cos\alpha=0{,}8$. По теореме косинусов $10^{2}=2b^{2}(1-\cos\alpha)=0{,}4b^{2}$, $b^{2}=250$. Площадь $=\tfrac12 b^{2}\sin\alpha=\tfrac12\cdot250\cdot0{,}6=75$.` },
|
||||
|
||||
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-systems', diff: 4,
|
||||
text: R`Пусть $(x;y)$ — целочисленное решение системы уравнений $\begin{cases}4y+x=-14,\\ 4y^{2}-4xy+x^{2}=16.\end{cases}$ Найдите сумму $x+y$.`,
|
||||
answer: '-5',
|
||||
sol: R`Второе уравнение: $(x-2y)^{2}=16$, $x-2y=\pm4$. С $x=-14-4y$ целое решение даёт $y=-3$, $x=-2$. Тогда $x+y=-5$.` },
|
||||
|
||||
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
|
||||
text: R`Найдите наибольшее целое решение неравенства $2^{3x-32}\cdot11^{x-6}>22^{2x-19}$.`,
|
||||
answer: '12',
|
||||
sol: R`$22^{2x-19}=2^{2x-19}\cdot11^{2x-19}$, поэтому неравенство равносильно $\left(\tfrac{2}{11}\right)^{x-13}>1$, то есть $x-13<0$, $x<13$. Наибольшее целое — $12$.` },
|
||||
|
||||
{ idx: 24, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
|
||||
text: R`Найдите количество корней уравнения $32\sin2x+8\cos4x=23$ на промежутке $\left[-\pi;\dfrac{3\pi}{4}\right]$.`,
|
||||
answer: '4',
|
||||
sol: R`Через $\cos4x=1-2\sin^{2}2x$ получаем $16\sin^{2}2x-32\sin2x+15=0$, откуда $\sin2x=0{,}75$. На указанном промежутке ($2x\in[-2\pi;\tfrac{3\pi}{2}]$) уравнение имеет $4$ корня.` },
|
||||
|
||||
{ idx: 25, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
|
||||
text: R`Геометрическая прогрессия со знаменателем $5$ содержит $10$ членов. Сумма всех членов прогрессии равна $24$. Найдите сумму всех членов прогрессии с чётными номерами.`,
|
||||
answer: '20',
|
||||
sol: R`Каждый чётный член в $5$ раз больше предыдущего нечётного, поэтому сумма чётных в $5$ раз больше суммы нечётных. Если сумма нечётных равна $s$, то $s+5s=24$, $s=4$, и сумма членов с чётными номерами равна $5s=20$.` },
|
||||
|
||||
{ idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-modulus', diff: 5,
|
||||
text: R`Найдите сумму корней уравнения $\big|(x-1)(x-6)\big|\cdot\big(|x+2|+|x-8|+|x-3|\big)=11(x-1)(6-x)$.`,
|
||||
answer: '13',
|
||||
sol: R`Правая часть неотрицательна лишь при $1\le x\le6$; на этом отрезке $|(x-1)(x-6)|=(x-1)(6-x)$. Уравнение даёт $(x-1)(6-x)\big(S-11\big)=0$, где $S=|x+2|+|x-8|+|x-3|=10+|x-3|$. Корни: $x=1,\ 6$ (множитель $0$) и $|x-3|=1$, то есть $x=2,\ 4$. Сумма $1+2+4+6=13$.` },
|
||||
|
||||
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
|
||||
text: R`Из города $A$ в город $B$, расстояние между которыми $100$ км, одновременно выезжают два автомобиля. Скорость первого автомобиля на $10$ км/ч больше скорости второго, но в пути он делает остановку на $50$ мин. Найдите наибольшее значение скорости (в км/ч) первого автомобиля, при движении с которой он прибудет в $B$ не позже второго.`,
|
||||
answer: '40',
|
||||
sol: R`Пусть скорость второго $v$. Условие $\dfrac{100}{v+10}+\dfrac56\le\dfrac{100}{v}$ приводит к $\dfrac56\le\dfrac{1000}{v(v+10)}$, то есть $v(v+10)\le1200$, $v\le30$. Наибольшая скорость первого $=30+10=40$.` },
|
||||
|
||||
{ idx: 28, type: 'open', topic: 'planimetry', subtopic: 'plan-circles', diff: 5,
|
||||
text: R`Из точки $A$ проведены к окружности радиуса $\dfrac43$ касательная $AB$ ($B$ — точка касания) и секущая $AC$, проходящая через центр окружности и пересекающая её в точках $D$ и $C$. Найдите площадь $S$ треугольника $ABC$, если длина секущей $AC$ в $3$ раза больше длины касательной. В ответ запишите $5S$.`,
|
||||
answer: '6',
|
||||
sol: R`$AB^{2}=AO^{2}-r^{2}$ и $AC=AO+r=3\,AB$ дают $AB=\tfrac{3r}{4}=1$, $AO=\tfrac53$, $AC=3$. В координатах $B=(0{,}6;0{,}8)$, высота из $B$ к $AC$ равна $0{,}8$, площадь $=\tfrac12\cdot3\cdot0{,}8=1{,}2$. Тогда $5S=6$.` },
|
||||
|
||||
{ idx: 29, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
|
||||
text: R`Если $\cos(\alpha+14^\circ)=\dfrac35$ и $0<\alpha+14^\circ<90^\circ$, то значение выражения $15\sqrt2\,\cos(\alpha+59^\circ)$ равно … .`,
|
||||
answer: '-3',
|
||||
sol: R`$\cos(\alpha+59^\circ)=\cos\big((\alpha+14^\circ)+45^\circ\big)=\tfrac{\sqrt2}{2}\big(\tfrac35-\tfrac45\big)=-\tfrac{\sqrt2}{10}$ (здесь $\sin(\alpha+14^\circ)=\tfrac45$). Тогда $15\sqrt2\cdot\left(-\tfrac{\sqrt2}{10}\right)=-3$.` },
|
||||
|
||||
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 5,
|
||||
text: R`Решите уравнение $\dfrac{30x^{2}}{x^{4}+25}=x^{2}+2\sqrt5\,x+8$. В ответ запишите значение выражения $x\cdot|x|$, где $x$ — корень уравнения.`,
|
||||
answer: '-5',
|
||||
sol: R`Левая часть $\le3$ (так как $x^{4}+25\ge10x^{2}$), правая часть $=(x+\sqrt5)^{2}+3\ge3$. Равенство возможно лишь при $x=-\sqrt5$. Тогда $x\cdot|x|=-\sqrt5\cdot\sqrt5=-5$.` },
|
||||
];
|
||||
|
||||
/* ── Сборка solution_html ────────────────────────────────────────────────── */
|
||||
function ansShowOf(t) {
|
||||
if (t.ansShow != null) return t.ansShow;
|
||||
if (t.type === 'mc') return `${t.answer})`;
|
||||
return `$${t.answer}$`;
|
||||
}
|
||||
function buildSolution(t) {
|
||||
const ans = ansShowOf(t);
|
||||
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
|
||||
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
|
||||
const EPS = 1e-6;
|
||||
function srvToNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||||
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
|
||||
const n = Number(t); return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
function checkAnswerServer(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
const c = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
|
||||
if (/^[^;]+;[^;]+$/.test(c)) return false;
|
||||
const cn = srvToNumber(c), un = srvToNumber(userInput);
|
||||
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
|
||||
return Math.abs(cn - un) < EPS;
|
||||
}
|
||||
|
||||
/* ── Валидация набора ──────────────────────────────────────────────────────── */
|
||||
const problems = [];
|
||||
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
|
||||
const seen = new Set();
|
||||
for (const t of TASKS) {
|
||||
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
|
||||
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
|
||||
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
|
||||
if (t.type === 'mc') {
|
||||
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
|
||||
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
|
||||
}
|
||||
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
|
||||
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
|
||||
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
|
||||
if (/−/.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
|
||||
}
|
||||
|
||||
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
|
||||
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
|
||||
if (require.main !== module) return;
|
||||
|
||||
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
|
||||
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
|
||||
|
||||
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
|
||||
console.log(`\n=== seed_ctmath_ct2013_v1 (${PROV}) variant=${VARIANT} ===`);
|
||||
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
|
||||
|
||||
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
|
||||
console.log('Типы:', JSON.stringify(byType), '\n');
|
||||
|
||||
console.log('idx | type | subtopic | d | answer');
|
||||
console.log('----+------+-----------------------+---+----------');
|
||||
for (const t of TASKS) {
|
||||
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
|
||||
problems.forEach(p => console.error(' - ' + p));
|
||||
console.error('\nЗапись отменена из-за ошибок валидации.');
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
|
||||
|
||||
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2013_v1.js --apply\n');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO exam_tasks
|
||||
(exam_key, variant, task_idx, task_type, text_html, figure_html,
|
||||
opts_json, answer, solution_html, topic, subtopic, difficulty)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
|
||||
task_type = excluded.task_type,
|
||||
text_html = excluded.text_html,
|
||||
figure_html = excluded.figure_html,
|
||||
opts_json = excluded.opts_json,
|
||||
answer = excluded.answer,
|
||||
solution_html = excluded.solution_html,
|
||||
topic = excluded.topic,
|
||||
subtopic = excluded.subtopic,
|
||||
difficulty = excluded.difficulty
|
||||
`);
|
||||
|
||||
let n = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const t of TASKS) {
|
||||
upsert.run(
|
||||
EXAM, VARIANT, t.idx, t.type,
|
||||
t.text,
|
||||
t.fig || null,
|
||||
t.type === 'mc' ? JSON.stringify(t.opts) : null,
|
||||
t.answer,
|
||||
buildSolution(t),
|
||||
t.topic, t.subtopic, t.diff
|
||||
);
|
||||
n++;
|
||||
}
|
||||
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
|
||||
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
|
||||
db.exec('COMMIT');
|
||||
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
|
||||
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
|
||||
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2013».\n`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,350 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
seed_ctmath_ct2017_v1.js
|
||||
Чистый вариант-пробник для трека exam-prep `ctmath`.
|
||||
|
||||
Источник: Централизованное тестирование (ЦТ) по математике, 2017, Вариант 1.
|
||||
Формат: Часть А = А1–А18, Часть В = В1–В12 (В1 — на соответствие). Всего 30 заданий.
|
||||
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2017\CT-2017.pdf
|
||||
(ответы — отдельный файл «Ответы ЦТ 2017.pdf», столбец «Вариант 1»).
|
||||
|
||||
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
|
||||
совпадение, включая B6=56, B8=-143, B11=121, B12=115. variant=118 (закрывает пробел
|
||||
между ЦТ-2016 и ЦТ-2018). Прогнан через дедуп-гейт (check_variant_dups.js) — без повторов.
|
||||
|
||||
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
|
||||
• А1 (вращение прямоугольников) → размеры сторона-ось/смежная даны числами (квадрат-сечение ⟺ ось=2·смежная → 3,5);
|
||||
• А3 (график движения) → путь на участке BC задан числами (52 км/ч);
|
||||
• А9 (треугольник по рисунку) → явно: BM — биссектриса угла B, AM/MC=AB/BC → 13,8;
|
||||
• А11 (фигура на сетке) → площадь фигуры дана числом ($18$ см² = 28 % трапеции → 64 2/7);
|
||||
• А14 (выбор параболы) → вершина/точка/направление ветвей в тексте.
|
||||
Без авторских ссылок (политика «все учебники наши»).
|
||||
|
||||
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
|
||||
Запуск:
|
||||
node backend/scripts/seed_ctmath_ct2017_v1.js # DRY-RUN (по умолчанию)
|
||||
node backend/scripts/seed_ctmath_ct2017_v1.js --apply # запись в БД
|
||||
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
const VARIANT = 118;
|
||||
const N_TASKS = 30;
|
||||
const PROV = 'ЦТ–2017, Вариант 1';
|
||||
const R = String.raw;
|
||||
|
||||
const L = ['а', 'б', 'в', 'г', 'д'];
|
||||
const mc = (...html) => html.map((h, i) => [L[i], h]);
|
||||
|
||||
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
|
||||
const TASKS = [
|
||||
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
|
||||
{ idx: 1, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
|
||||
text: R`Прямоугольник вращают вокруг указанной стороны (оси), образуя цилиндр. Осевым сечением цилиндра должен быть квадрат. Укажите номера прямоугольников (ось $\times$ смежная сторона): $1)\ 8\times8$; $\ 2)\ 8\times16$; $\ 3)\ 8\times4$; $\ 4)\ 4\times8$; $\ 5)\ 16\times8$.`,
|
||||
opts: mc('$2,\ 3$', '$1,\ 5$', '$3,\ 5$', '$2,\ 4$', '$1,\ 3,\ 5$'),
|
||||
answer: 'в',
|
||||
sol: R`Осевое сечение — прямоугольник «ось $\times$ диаметр $=$ ось $\times2\cdot$смежная». Это квадрат, когда ось $=2\cdot$смежная: для $8\times4$ ($8=2\cdot4$) и $16\times8$ ($16=2\cdot8$). Значит $3$ и $5$.` },
|
||||
|
||||
{ idx: 2, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
|
||||
text: R`Выразите $737$ см $8$ мм в метрах с точностью до сотых.`,
|
||||
opts: mc('$0{,}74$ м', '$7{,}37$ м', '$7{,}378$ м', '$7{,}38$ м', '$73{,}78$ м'),
|
||||
answer: 'г',
|
||||
sol: R`$737$ см $8$ мм $=7{,}378$ м $\approx7{,}38$ м.` },
|
||||
|
||||
{ idx: 3, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
|
||||
text: R`По графику движения автомобиля на участке $BC$ путь изменился с $52$ км до $104$ км за $1$ ч. Найдите скорость движения на участке $BC$.`,
|
||||
opts: mc('$26$ км/ч', '$52$ км/ч', '$78$ км/ч', '$104$ км/ч', '$60$ км/ч'),
|
||||
answer: 'б',
|
||||
sol: R`$v=\dfrac{104-52}{1}=52$ км/ч.` },
|
||||
|
||||
{ idx: 4, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
|
||||
text: R`Выразите $a$ из равенства $\dfrac{3}{2b+1}=\dfrac{6}{a-b}$.`,
|
||||
opts: mc('$a=5b+2$', '$a=5b-2$', '$a=15b-6$', '$a=15b+6$', '$a=3b+1$'),
|
||||
answer: 'а',
|
||||
sol: R`$3(a-b)=6(2b+1)$, $a-b=4b+2$, $a=5b+2$.` },
|
||||
|
||||
{ idx: 5, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
|
||||
text: R`Значение выражения $8\sqrt3+\dfrac18\sqrt{192}$ равно:`,
|
||||
opts: mc('$16\sqrt3$', '$\sqrt{195}$', '$\dfrac{65\sqrt{195}}{8}$', '$\dfrac{6\sqrt3}{8}$', '$9\sqrt3$'),
|
||||
answer: 'д',
|
||||
sol: R`$\sqrt{192}=8\sqrt3$, поэтому $\dfrac18\cdot8\sqrt3=\sqrt3$ и $8\sqrt3+\sqrt3=9\sqrt3$.` },
|
||||
|
||||
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 1,
|
||||
text: R`Последовательность $(a_n)$ задана формулой $a_n=3n^{2}-8n+9$. Второй член этой последовательности равен:`,
|
||||
opts: mc('$12$', '$-16$', '$5$', '$16$', '$6$'),
|
||||
answer: 'в',
|
||||
sol: R`$a_2=3\cdot4-16+9=5$.` },
|
||||
|
||||
{ idx: 7, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 2,
|
||||
text: R`Значение выражения $7\cos^{2}34^\circ+10\sin30^\circ+7\sin^{2}34^\circ$ равно:`,
|
||||
opts: mc('$12$', '$17$', '$24$', '$7+10\sqrt3$', '$14+5\sqrt3$'),
|
||||
answer: 'а',
|
||||
sol: R`$7(\cos^{2}34^\circ+\sin^{2}34^\circ)+10\cdot\tfrac12=7+5=12$.` },
|
||||
|
||||
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
|
||||
text: R`Среди утверждений укажите номер верного.<br>$1)$ число $451$ кратно числу $5$; $\ 2)$ число $9$ кратно числу $35$; $\ 3)$ число $2$ кратно числу $14$; $\ 4)$ число $116$ кратно числу $1$; $\ 5)$ число $43$ кратно числу $0$.`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'г',
|
||||
sol: R`Любое целое кратно $1$, поэтому $116$ кратно $1$ — верно (утверждение 4). Остальные неверны.` },
|
||||
|
||||
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
|
||||
text: R`В треугольнике $ABC$ отрезок $BM$ — биссектриса угла $B$ ($M$ на $AC$). Известно, что $AC=32$, $AM=12$, $BC=23$. Найдите длину стороны $AB$.`,
|
||||
opts: mc('$10{,}2$', '$14{,}6$', '$13{,}8$', '$13{,}5$', '$10{,}4$'),
|
||||
answer: 'в',
|
||||
sol: R`Биссектриса делит сторону в отношении прилежащих сторон: $\dfrac{AM}{MC}=\dfrac{AB}{BC}$. $MC=32-12=20$, поэтому $AB=BC\cdot\dfrac{AM}{MC}=23\cdot\dfrac{12}{20}=13{,}8$.` },
|
||||
|
||||
{ idx: 10, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
|
||||
text: R`Результат упрощения выражения $\sqrt{(2x-4{,}6)^{2}}+4{,}6$ при $-1<x<1$ имеет вид:`,
|
||||
opts: mc('$9{,}2-2x$', '$-2x-9{,}2$', '$2x+9{,}2$', '$2x$', '$-2x$'),
|
||||
answer: 'а',
|
||||
sol: R`При $-1<x<1$ имеем $2x-4{,}6<0$, поэтому $\sqrt{(2x-4{,}6)^{2}}=4{,}6-2x$, и сумма $=(4{,}6-2x)+4{,}6=9{,}2-2x$.` },
|
||||
|
||||
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
|
||||
text: R`Площадь фигуры на клетчатой бумаге равна $18$ см² и составляет 28 % площади некоторой трапеции. Найдите площадь трапеции (в квадратных сантиметрах).`,
|
||||
opts: mc('$504$', '$64\tfrac27$', '$35$', '$72\tfrac34$', '$155\tfrac59$'),
|
||||
answer: 'б',
|
||||
sol: R`$\dfrac{18}{0{,}28}=\dfrac{1800}{28}=\dfrac{450}{7}=64\tfrac27$ см².` },
|
||||
|
||||
{ idx: 12, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
|
||||
text: R`Определите остроугольный треугольник по длинам его сторон: $\triangle ABC$ ($8;15;17$), $\triangle MNK$ ($4;6;8$), $\triangle BDC$ ($3;4;5$), $\triangle FBC$ ($7;8;9$), $\triangle CDE$ ($5;11;13$).`,
|
||||
opts: mc('$\triangle ABC$', '$\triangle MNK$', '$\triangle BDC$', '$\triangle FBC$', '$\triangle CDE$'),
|
||||
answer: 'г',
|
||||
sol: R`Треугольник остроугольный, если квадрат большей стороны меньше суммы квадратов двух других. Только для $\triangle FBC$: $9^{2}=81<7^{2}+8^{2}=113$. ($ABC$ и $BDC$ прямоугольные, $MNK$ и $CDE$ тупоугольные.)` },
|
||||
|
||||
{ idx: 13, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
|
||||
text: R`Купили $m$ ручек по цене $2$ руб $3$ коп за штуку и $178$ тетрадей по цене $a$ коп за штуку. Составьте выражение, определяющее стоимость покупки (в рублях).`,
|
||||
opts: mc('$2{,}03m+178a$', '$2{,}03m+1{,}78a$', '$2{,}3m+1{,}78a$', '$2{,}3m+17{,}8a$', '$2{,}03m+17{,}8a$'),
|
||||
answer: 'б',
|
||||
sol: R`$2$ руб $3$ коп $=2{,}03$ руб, $178$ тетрадей по $a$ коп $=178a$ коп $=1{,}78a$ руб. Итого $2{,}03m+1{,}78a$.` },
|
||||
|
||||
{ idx: 14, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
|
||||
text: R`Парабола проходит через точку $(0;3)$, имеет вершину в точке $(-1;1)$, ветви направлены вверх. Укажите номер её уравнения.<br>$1)\ y=x^{2}+4x+3$; $\ 2)\ y=x^{2}-4x-3$; $\ 3)\ y=2x^{2}+4x+3$; $\ 4)\ y=2x^{2}+4x-3$; $\ 5)\ y=2x^{2}-4x+3$.`,
|
||||
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
|
||||
answer: 'в',
|
||||
sol: R`У $y=2x^{2}+4x+3$ вершина в точке $(-1;1)$ ($x=-\tfrac{4}{4}=-1$, $y=2-4+3=1$), $y(0)=3$, ветви вверх. Это уравнение 3.` },
|
||||
|
||||
{ idx: 15, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
|
||||
text: R`$ABCDA_1B_1C_1D_1$ — куб. Точки $M$ и $N$ — середины рёбер $AD$ и $DC$, точка $K$ на ребре $A_1D_1$ с $KA_1:KD_1=1:3$. Сечением куба плоскостью, проходящей через точки $M$, $N$ и $K$, является:`,
|
||||
opts: mc('восьмиугольник', 'треугольник', 'четырёхугольник', 'пятиугольник', 'шестиугольник'),
|
||||
answer: 'в',
|
||||
sol: R`Плоскость отсекает ребро $DD_1$ (оба конца по одну сторону) и пересекает четыре ребра: $AD$ (точка $M$), $DC$ ($N$), $A_1D_1$ ($K$) и $D_1C_1$. Четыре точки — сечение является четырёхугольником.` },
|
||||
|
||||
{ idx: 16, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
|
||||
text: R`Найдите сумму наименьшего и наибольшего целых решений двойного неравенства $-448{,}9<2{,}9+9x<23{,}6$.`,
|
||||
opts: mc('$-52$', '$-47$', '$-49$', '$-48$', '$-53$'),
|
||||
answer: 'г',
|
||||
sol: R`$-451{,}8<9x<20{,}7$, то есть $-50{,}2<x<2{,}3$. Целые от $-50$ до $2$; сумма наименьшего и наибольшего $-50+2=-48$.` },
|
||||
|
||||
{ idx: 17, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 3,
|
||||
text: R`Через точку $A$ высоты $SO$ конуса проведена плоскость, параллельная основанию. Определите, во сколько раз площадь основания конуса больше площади полученного сечения, если $SA:AO=2:3$.`,
|
||||
opts: mc('$6\tfrac14$', '$7\tfrac14$', '$2\tfrac14$', '$1\tfrac12$', '$2\tfrac12$'),
|
||||
answer: 'а',
|
||||
sol: R`Сечение подобно основанию с коэффициентом $\dfrac{SA}{SO}=\dfrac{2}{5}$. Отношение площадей $\left(\dfrac{SO}{SA}\right)^{2}=\left(\dfrac52\right)^{2}=6\tfrac14$.` },
|
||||
|
||||
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
|
||||
text: R`Укажите (в градусах) наименьший положительный корень уравнения $\cos(6x-72^\circ)=\dfrac{\sqrt3}{2}$.`,
|
||||
opts: mc('$5^\circ$', '$102^\circ$', '$17^\circ$', '$42^\circ$', '$7^\circ$'),
|
||||
answer: 'д',
|
||||
sol: R`$6x-72^\circ=\pm30^\circ+360^\circ k$, поэтому $x=17^\circ+60^\circ k$ или $x=7^\circ+60^\circ k$. Наименьший положительный корень $7^\circ$.` },
|
||||
|
||||
// ── Часть B: В1–В12 ──────────────────────────────────────────────────────
|
||||
{ idx: 19, type: 'long', topic: 'functions', subtopic: 'fn-graphs', diff: 3,
|
||||
text: R`Для начала каждого из предложений А–В подберите его окончание $1$–$6$.<br>А) Окружность с центром $(-8;-2)$ и радиусом $4$ задаётся уравнением …<br>Б) Уравнение прямой, проходящей через точку $(-8;2)$ параллельно прямой $y=\tfrac14 x$, имеет вид …<br>В) График обратной пропорциональности, проходящий через точку $\left(\tfrac12;-\tfrac12\right)$, задаётся уравнением …<br>Окончания: $1)\ xy=2$; $\ 2)\ (x-8)^{2}+(y-2)^{2}=4$; $\ 3)\ -\tfrac14 x+y=4$; $\ 4)\ (x+8)^{2}+(y+2)^{2}=16$; $\ 5)\ 4xy+1=0$; $\ 6)\ \tfrac14 x+y=2$.`,
|
||||
answer: 'А4Б3В5',
|
||||
ansShow: 'А4Б3В5',
|
||||
sol: R`А) $(x+8)^{2}+(y+2)^{2}=16$ (окончание 4). Б) $y-2=\tfrac14(x+8)$, то есть $-\tfrac14 x+y=4$ (окончание 3). В) $y=\tfrac{k}{x}$ через $\left(\tfrac12;-\tfrac12\right)$ даёт $k=-\tfrac14$, то есть $xy=-\tfrac14$, $4xy+1=0$ (окончание 5). Ответ: А4Б3В5.` },
|
||||
|
||||
{ idx: 20, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
|
||||
text: R`Конфеты в коробке упаковываются рядами, причём количество конфет в каждом ряду на $4$ больше количества рядов. Дизайн коробки изменили: добавили $2$ ряда, а в каждом ряду — по $1$ конфете. В результате количество конфет в коробке увеличилось на $25$. Сколько конфет упаковывалось в коробку первоначально?`,
|
||||
answer: '45',
|
||||
sol: R`Пусть рядов $r$, в ряду $r+4$. Тогда $(r+2)(r+5)-r(r+4)=3r+10=25$, $r=5$. Первоначально $5\cdot9=45$ конфет.` },
|
||||
|
||||
{ idx: 21, type: 'open', topic: 'expressions', subtopic: 'expr-polynomials', diff: 3,
|
||||
text: R`Известно, что при $a$, равном $-2$ и $4$, значение выражения $4a^{3}+3a^{2}-ab+c$ равно нулю. Найдите значение выражения $b+c$.`,
|
||||
answer: '-34',
|
||||
sol: R`При $a=-2$: $-20+2b+c=0$; при $a=4$: $304-4b+c=0$. Вычитая, $6b=324$, $b=54$, тогда $c=-88$, и $b+c=-34$.` },
|
||||
|
||||
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 4,
|
||||
text: R`Найдите произведение корней (корень, если он единственный) уравнения $x^{2}-5x-3=4\sqrt{x^{2}-5x+9}$.`,
|
||||
answer: '-27',
|
||||
sol: R`Пусть $u=x^{2}-5x$. Тогда $u-3=4\sqrt{u+9}$ ($u\ge3$); возведя в квадрат, $u^{2}-22u-135=0$, $u=27$. Из $x^{2}-5x-27=0$ произведение корней $-27$.` },
|
||||
|
||||
{ idx: 23, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
|
||||
text: R`В параллелограмме с острым углом $45^\circ$ точка пересечения диагоналей удалена от прямых, содержащих неравные стороны, на расстояния $\dfrac{7\sqrt2}{2}$ и $2$. Найдите площадь параллелограмма.`,
|
||||
answer: '56',
|
||||
sol: R`Расстояние от центра до стороны — половина высоты. Высоты $H_1=7\sqrt2$ и $H_2=4$. Из $H=l\sin45^\circ$: стороны $b=14$, $a=4\sqrt2$. Площадь $=b\cdot H_2=14\cdot4=56$.` },
|
||||
|
||||
{ idx: 24, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
|
||||
text: R`Пусть $x_0$ — наибольший корень уравнения $\log_2^{2}\dfrac{x}{32}+4\log_2 x-52=0$. Найдите значение выражения $7\sqrt[3]{x_0}$.`,
|
||||
answer: '56',
|
||||
sol: R`Пусть $t=\log_2 x$. Тогда $(t-5)^{2}+4t-52=0$, $t^{2}-6t-27=0$, $t=9$ или $t=-3$. Наибольший корень $x_0=2^{9}=512$, и $7\sqrt[3]{512}=7\cdot8=56$.` },
|
||||
|
||||
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
|
||||
text: R`Решите неравенство $\left(\dfrac{1}{5-\sqrt{24}}\right)^{x+6}\ge\left(5-\sqrt{24}\right)^{\frac{4x+25}{x+4}}$. В ответ запишите сумму целых решений, принадлежащих промежутку $[-20;-2]$.`,
|
||||
answer: '-12',
|
||||
sol: R`Так как $\dfrac{1}{5-\sqrt{24}}=(5-\sqrt{24})^{-1}$ и $0<5-\sqrt{24}<1$, неравенство равносильно $-(x+6)\le\dfrac{4x+25}{x+4}$, что приводит к $\dfrac{(x+7)^{2}}{x+4}\ge0$. Решение: $x>-4$ или $x=-7$. На $[-20;-2]$ целые $-7,-3,-2$; их сумма $-12$.` },
|
||||
|
||||
{ idx: 26, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
|
||||
text: R`Найдите увеличенное в $9$ раз произведение абсцисс точек пересечения прямой $y=12$ и графика нечётной функции, которая определена на $(-\infty;0)\cup(0;+\infty)$ и при $x>0$ задаётся формулой $y=2^{3x-8}-20$.`,
|
||||
answer: '-143',
|
||||
sol: R`При $x>0$: $2^{3x-8}-20=12$, $2^{3x-8}=32$, $x=\tfrac{13}{3}$. По нечётности при $x<0$ получаем $x=-\tfrac{11}{3}$. Произведение $\tfrac{13}{3}\cdot\left(-\tfrac{11}{3}\right)=-\tfrac{143}{9}$; увеличенное в $9$ раз — $-143$.` },
|
||||
|
||||
{ idx: 27, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 4,
|
||||
text: R`Найдите площадь полной поверхности прямой треугольной призмы, описанной около шара, если площадь основания призмы равна $7{,}5$.`,
|
||||
answer: '45',
|
||||
sol: R`У описанной около шара призмы высота $h=2r$, а в основании вписана окружность радиуса $r$, поэтому площадь основания $S=rp=7{,}5$. Боковая поверхность $=2p\cdot2r=4\cdot rp=30$. Полная $=2\cdot7{,}5+30=45$.` },
|
||||
|
||||
{ idx: 28, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
|
||||
text: R`Найдите произведение наибольшего целого решения на количество целых решений неравенства $\dfrac{16}{6+|24-x|}>|24-x|$.`,
|
||||
answer: '75',
|
||||
sol: R`Пусть $u=|24-x|\ge0$. Тогда $16>u(6+u)$, $u^{2}+6u-16<0$, $0\le u<2$. Значит $|24-x|<2$, то есть $22<x<26$. Целые $23,24,25$ ($3$ решения); наибольшее $25$. Произведение $25\cdot3=75$.` },
|
||||
|
||||
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
|
||||
text: R`Первые члены арифметической и геометрической прогрессий одинаковы и равны $1$, третьи члены также одинаковы, а вторые отличаются на $18$. Найдите шестой член арифметической прогрессии, если все члены обеих прогрессий положительны.`,
|
||||
answer: '121',
|
||||
sol: R`$1+2d=q^{2}$ и $\left|\tfrac{q^{2}+1}{2}-q\right|=\tfrac{(q-1)^{2}}{2}=18$, откуда $q=7$ (положительные члены), $d=24$. Тогда $a_6=1+5\cdot24=121$.` },
|
||||
|
||||
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
|
||||
text: R`$ABCDA_1B_1C_1D_1$ — прямая четырёхугольная призма, объём которой равен $960$. Основанием призмы является параллелограмм $ABCD$. Точки $M$ и $N$ принадлежат рёбрам $A_1D_1$ и $C_1D_1$ так, что $A_1M:A_1D_1=1:2$, $D_1N:NC_1=1:3$. Отрезки $A_1N$ и $B_1M$ пересекаются в точке $K$. Найдите объём пирамиды $SB_1KNC_1$, если $S\in B_1D$ и $B_1S:SD=3:1$.`,
|
||||
answer: '115',
|
||||
sol: R`Координатным методом (положения $K$, $N$ на верхней грани и точки $S$ на диагонали $B_1D$) объём пирамиды составляет $\dfrac{23}{192}$ объёма призмы: $\dfrac{23}{192}\cdot960=115$.` },
|
||||
];
|
||||
|
||||
/* ── Сборка solution_html ────────────────────────────────────────────────── */
|
||||
function ansShowOf(t) {
|
||||
if (t.ansShow != null) return t.ansShow;
|
||||
if (t.type === 'mc') return `${t.answer})`;
|
||||
return `$${t.answer}$`;
|
||||
}
|
||||
function buildSolution(t) {
|
||||
const ans = ansShowOf(t);
|
||||
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
|
||||
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
|
||||
const EPS = 1e-6;
|
||||
function srvToNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||||
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
|
||||
const n = Number(t); return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
function checkAnswerServer(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
const c = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
|
||||
if (/^[^;]+;[^;]+$/.test(c)) return false;
|
||||
const cn = srvToNumber(c), un = srvToNumber(userInput);
|
||||
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
|
||||
return Math.abs(cn - un) < EPS;
|
||||
}
|
||||
|
||||
/* ── Валидация набора ──────────────────────────────────────────────────────── */
|
||||
const problems = [];
|
||||
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
|
||||
const seen = new Set();
|
||||
for (const t of TASKS) {
|
||||
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
|
||||
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
|
||||
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
|
||||
if (t.type === 'mc') {
|
||||
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
|
||||
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
|
||||
}
|
||||
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
|
||||
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
|
||||
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
|
||||
if (/−/.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
|
||||
}
|
||||
|
||||
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
|
||||
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
|
||||
if (require.main !== module) return;
|
||||
|
||||
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
|
||||
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
|
||||
|
||||
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
|
||||
console.log(`\n=== seed_ctmath_ct2017_v1 (${PROV}) variant=${VARIANT} ===`);
|
||||
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
|
||||
|
||||
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
|
||||
console.log('Типы:', JSON.stringify(byType), '\n');
|
||||
|
||||
console.log('idx | type | subtopic | d | answer');
|
||||
console.log('----+------+-----------------------+---+----------');
|
||||
for (const t of TASKS) {
|
||||
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
|
||||
problems.forEach(p => console.error(' - ' + p));
|
||||
console.error('\nЗапись отменена из-за ошибок валидации.');
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
|
||||
|
||||
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2017_v1.js --apply\n');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO exam_tasks
|
||||
(exam_key, variant, task_idx, task_type, text_html, figure_html,
|
||||
opts_json, answer, solution_html, topic, subtopic, difficulty)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
|
||||
task_type = excluded.task_type,
|
||||
text_html = excluded.text_html,
|
||||
figure_html = excluded.figure_html,
|
||||
opts_json = excluded.opts_json,
|
||||
answer = excluded.answer,
|
||||
solution_html = excluded.solution_html,
|
||||
topic = excluded.topic,
|
||||
subtopic = excluded.subtopic,
|
||||
difficulty = excluded.difficulty
|
||||
`);
|
||||
|
||||
let n = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const t of TASKS) {
|
||||
upsert.run(
|
||||
EXAM, VARIANT, t.idx, t.type,
|
||||
t.text,
|
||||
t.fig || null,
|
||||
t.type === 'mc' ? JSON.stringify(t.opts) : null,
|
||||
t.answer,
|
||||
buildSolution(t),
|
||||
t.topic, t.subtopic, t.diff
|
||||
);
|
||||
n++;
|
||||
}
|
||||
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
|
||||
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
|
||||
db.exec('COMMIT');
|
||||
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
|
||||
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
|
||||
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2017».\n`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
db.close();
|
||||
@@ -1,7 +1,10 @@
|
||||
const db = require('../db/db');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { audit } = require('../utils/audit');
|
||||
const { purgeAccessFor } = require('../services/contentAccess');
|
||||
const sysReset = require('../services/systemReset');
|
||||
|
||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
|
||||
|
||||
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
|
||||
function getAllSessions(req, res) {
|
||||
const { subject, user_id } = req.query;
|
||||
const { subject, user_id, status } = req.query;
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
|
||||
const offset = Math.max(0, Number(req.query.offset) || 0);
|
||||
|
||||
const where = ['ts.status = \'completed\''];
|
||||
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
|
||||
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
|
||||
where.push('ts.status = ?'); params.push(status);
|
||||
}
|
||||
if (subject) { where.push('s.slug = ?'); params.push(subject); }
|
||||
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
|
||||
|
||||
@@ -314,7 +322,7 @@ function getAllSessions(req, res) {
|
||||
FROM test_sessions ts
|
||||
LEFT JOIN subjects s ON s.id = ts.subject_id
|
||||
JOIN users u ON u.id = ts.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY ts.started_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params);
|
||||
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
|
||||
function updateFeatures(req, res) {
|
||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik'];
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
|
||||
const updates = req.body;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||
@@ -586,6 +594,56 @@ function updateFreeStudentFeatures(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
|
||||
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
|
||||
function getResetPlan(req, res) {
|
||||
try {
|
||||
const plan = sysReset.classify(db);
|
||||
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
|
||||
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── POST /api/admin/reset-system ──────────────────────────────────────
|
||||
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
|
||||
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
|
||||
стирает остальных пользователей + активность, переназначает контент. */
|
||||
function resetSystem(req, res) {
|
||||
const confirm = (req.body && req.body.confirm) || '';
|
||||
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
|
||||
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
|
||||
}
|
||||
const keptId = req.user.id;
|
||||
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
|
||||
let backupName = null;
|
||||
try {
|
||||
const dbPath = db._path;
|
||||
if (!dbPath) throw new Error('путь к БД неизвестен');
|
||||
const backupsDir = path.join(path.dirname(dbPath), 'backups');
|
||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
|
||||
const d = new Date();
|
||||
const p2 = n => String(n).padStart(2, '0');
|
||||
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
|
||||
backupName = `learnspace-prereset-${ts}.db`;
|
||||
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
|
||||
}
|
||||
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
|
||||
let summary;
|
||||
try {
|
||||
summary = sysReset.runReset(db, keptId);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
|
||||
}
|
||||
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
|
||||
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
|
||||
res.json({ ok: true, backup: backupName, ...summary });
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
|
||||
function getAuditLog(req, res) {
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
||||
@@ -659,8 +717,6 @@ function clearSecurityLog(req, res) {
|
||||
|
||||
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||
const sse = require('../sse');
|
||||
@@ -1157,6 +1213,7 @@ module.exports = {
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getResetPlan, resetSystem,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
|
||||
@@ -2,6 +2,7 @@ const db = require('../db/db');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { SESSION_MODES } = require('../constants');
|
||||
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
|
||||
|
||||
const VALID_ASSIGN_MODES = SESSION_MODES;
|
||||
|
||||
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
|
||||
function myAssignments(req, res) {
|
||||
const uid = req.user.id;
|
||||
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
|
||||
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
|
||||
function assignmentRowsForUser(uid) {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
|
||||
@@ -267,6 +268,7 @@ function myAssignments(req, res) {
|
||||
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
|
||||
tp.paragraphs_read AS textbook_read,
|
||||
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
|
||||
a.created_by AS created_by,
|
||||
latest.session_id,
|
||||
ts.score, ts.total, ts.status AS session_status,
|
||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
||||
@@ -295,6 +297,7 @@ function myAssignments(req, res) {
|
||||
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
|
||||
tp.paragraphs_read AS textbook_read,
|
||||
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
|
||||
a.created_by AS created_by,
|
||||
latest.session_id,
|
||||
ts.score, ts.total, ts.status AS session_status,
|
||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
||||
@@ -334,8 +337,78 @@ function myAssignments(req, res) {
|
||||
// Strip raw paragraphs_read JSON from response (not needed by client)
|
||||
delete r.textbook_read;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
|
||||
function myAssignments(req, res) {
|
||||
res.json(assignmentRowsForUser(req.user.id));
|
||||
}
|
||||
|
||||
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
|
||||
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
|
||||
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
|
||||
function classOutstanding(req, res) {
|
||||
const cid = req.params.id;
|
||||
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
|
||||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.name, u.email FROM class_members cm
|
||||
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
|
||||
`).all(cid);
|
||||
|
||||
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
|
||||
const subRows = db.prepare(`
|
||||
SELECT s.assignment_id, s.student_id, s.status
|
||||
FROM submissions s
|
||||
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
|
||||
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
|
||||
`).all(cid);
|
||||
const subMap = new Map();
|
||||
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
|
||||
|
||||
const now = Date.now();
|
||||
const cidNum = Number(cid);
|
||||
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
|
||||
|
||||
const students = members.map(m => {
|
||||
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
|
||||
const rows = assignmentRowsForUser(m.id).filter(r =>
|
||||
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
|
||||
);
|
||||
const pending = [];
|
||||
for (const r of rows) {
|
||||
const t = AssignmentUtils.type(r);
|
||||
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
|
||||
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
|
||||
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
|
||||
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
|
||||
let status = overdue ? 'overdue' : 'not_started';
|
||||
if (st === 'revision') status = 'revision'; // вернули на доработку
|
||||
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
|
||||
pending.push({
|
||||
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
|
||||
status, is_homework: r.is_homework ? 1 : 0,
|
||||
scope: r.class_id === cidNum ? 'class' : 'direct',
|
||||
});
|
||||
}
|
||||
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
|
||||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
|
||||
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
|
||||
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
|
||||
pending.forEach(p => { counts[p.status]++; });
|
||||
return { id: m.id, name: m.name, email: m.email, pending, counts };
|
||||
});
|
||||
|
||||
const summary = {
|
||||
students_total: members.length,
|
||||
debtors: students.filter(s => s.counts.total > 0).length,
|
||||
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
|
||||
};
|
||||
res.json({ className: cls.name, summary, students });
|
||||
}
|
||||
|
||||
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
|
||||
@@ -732,6 +805,7 @@ module.exports = {
|
||||
deleteAssignment,
|
||||
teacherAssignments,
|
||||
myAssignments,
|
||||
classOutstanding,
|
||||
startAssignment,
|
||||
assignmentResults,
|
||||
assignmentQuestionStats,
|
||||
|
||||
@@ -41,6 +41,15 @@ function createSession(req, res) {
|
||||
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
|
||||
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
|
||||
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
|
||||
try {
|
||||
const sse = require('../../sse');
|
||||
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
|
||||
if (class_id) sse.emitToClass(class_id, payload);
|
||||
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
|
||||
sse.emit(teacher.id, payload);
|
||||
} catch { /* SSE недоступен — не критично */ }
|
||||
res.json(session);
|
||||
}
|
||||
|
||||
@@ -74,6 +83,17 @@ function endSession(req, res) {
|
||||
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
||||
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
|
||||
try {
|
||||
const sse = require('../../sse');
|
||||
const payload = { type: 'classroom_live', state: 'ended', sessionId };
|
||||
if (session.class_id) sse.emitToClass(session.class_id, payload);
|
||||
else {
|
||||
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
for (const r of invited) sse.emit(r.user_id, payload);
|
||||
}
|
||||
sse.emit(session.teacher_id, payload);
|
||||
} catch { /* SSE недоступен — не критично */ }
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
/* clientErrorController — приём ошибок из браузера пользователя.
|
||||
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
|
||||
Запись не должна ронять запрос — любые сбои глушим. */
|
||||
const db = require('../db/db');
|
||||
|
||||
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
|
||||
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
|
||||
|
||||
function report(req, res) {
|
||||
const b = req.body || {};
|
||||
const message = (clamp(b.message, MAX_MSG) || '').trim();
|
||||
if (!message) return res.status(400).json({ error: 'message required' });
|
||||
|
||||
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
|
||||
const route = clamp(b.url || b.route, MAX_ROUTE);
|
||||
let stack = clamp(b.stack, MAX_STACK);
|
||||
// если стека нет — собираем источник:строка:колонка
|
||||
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('client', message, stack, route, kind, req.user.id);
|
||||
} catch { /* лог не должен ломать ответ */ }
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { report };
|
||||
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
|
||||
}
|
||||
|
||||
function onLabExperiment(userId, reactionsDiscovered) {
|
||||
if (!isGamificationEnabled()) return; // master kill-switch
|
||||
stmts.incrLabExp.run(userId);
|
||||
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
|
||||
awardXP(userId, 15, 'lab_experiment');
|
||||
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
|
||||
}
|
||||
|
||||
function updateChallenges(userId, score, total, subjectSlug, topicId) {
|
||||
if (!isGamificationEnabled()) return; // master kill-switch
|
||||
const week = _currentWeek();
|
||||
const pct = total > 0 ? Math.round(score / total * 100) : 0;
|
||||
const challenges = stmts.getOpenChallenges.all(userId, week);
|
||||
|
||||
@@ -7,13 +7,16 @@ function list(req, res) {
|
||||
const args = [];
|
||||
let where = '1=1';
|
||||
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),
|
||||
// не показываем их во вкладке «Тесты (шаблоны)» админки.
|
||||
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
|
||||
let rows = db.prepare(`
|
||||
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
|
||||
u.name AS creator_name,
|
||||
COUNT(tq.question_id) AS question_count
|
||||
FROM tests t
|
||||
@@ -22,18 +25,19 @@ function list(req, res) {
|
||||
WHERE ${where}
|
||||
GROUP BY t.id ORDER BY t.created_at DESC
|
||||
`).all(...args);
|
||||
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
|
||||
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 (!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 r = db.prepare(
|
||||
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
|
||||
'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, available_to_students ? 1 : 0, req.user.id);
|
||||
res.status(201).json({ id: r.lastInsertRowid });
|
||||
}
|
||||
|
||||
@@ -76,13 +80,18 @@ function getOne(req, res) {
|
||||
|
||||
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
|
||||
function update(req, res) {
|
||||
const { title, subject_slug, description, show_answers, time_limit } = req.body;
|
||||
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;
|
||||
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
|
||||
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
|
||||
tl !== undefined ? tl : t.time_limit,
|
||||
t.id);
|
||||
const b = req.body;
|
||||
const t = req.resource; // ownership verified by requireOwnership middleware
|
||||
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
|
||||
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
|
||||
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
|
||||
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
const db = require('../db/db');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
|
||||
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
|
||||
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
const STATUS_LABEL = {
|
||||
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
|
||||
done: 'Готово', declined: 'Отклонено',
|
||||
};
|
||||
|
||||
function clampStr(v, max) {
|
||||
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
|
||||
}
|
||||
|
||||
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
|
||||
function list(req, res) {
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const { status, category } = req.query;
|
||||
const where = [];
|
||||
const args = [];
|
||||
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
|
||||
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
|
||||
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
|
||||
const sql = `
|
||||
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
|
||||
w.created_at, w.updated_at,
|
||||
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
|
||||
0 AS _pad
|
||||
FROM wishes w
|
||||
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
|
||||
w.updated_at DESC`;
|
||||
const rows = db.prepare(sql).all(...args);
|
||||
|
||||
let counts = null;
|
||||
if (isAdmin) {
|
||||
counts = {};
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
|
||||
}
|
||||
res.json({ wishes: rows, counts, isAdmin });
|
||||
}
|
||||
|
||||
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
|
||||
function create(req, res) {
|
||||
const title = clampStr(req.body?.title, 200);
|
||||
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
|
||||
const body = clampStr(req.body?.body, 4000);
|
||||
let category = String(req.body?.category || 'other');
|
||||
if (!CATEGORIES.includes(category)) category = 'other';
|
||||
|
||||
const info = db.prepare(
|
||||
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
|
||||
).run(req.user.id, title, body || null, category);
|
||||
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
|
||||
res.status(201).json(row);
|
||||
}
|
||||
|
||||
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
|
||||
function update(req, res) {
|
||||
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
|
||||
const fields = [];
|
||||
const args = [];
|
||||
let newStatus = null;
|
||||
if (req.body?.status !== undefined) {
|
||||
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
|
||||
if (req.body.status !== wish.status) newStatus = req.body.status;
|
||||
fields.push('status = ?'); args.push(req.body.status);
|
||||
}
|
||||
if (req.body?.admin_note !== undefined) {
|
||||
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
|
||||
}
|
||||
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
|
||||
|
||||
// Уведомить автора при смене статуса (durable + SSE).
|
||||
if (newStatus && wish.user_id !== req.user.id) {
|
||||
try {
|
||||
pushNotif(wish.user_id, 'wish_update',
|
||||
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
|
||||
} catch { /* notif не критичен */ }
|
||||
}
|
||||
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
|
||||
}
|
||||
|
||||
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
|
||||
function remove(req, res) {
|
||||
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwner = wish.user_id === req.user.id;
|
||||
if (!isAdmin && !(isOwner && wish.status === 'new'))
|
||||
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
|
||||
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
|
||||
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
|
||||
};
|
||||
};
|
||||
|
||||
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
|
||||
module.exports = db;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
|
||||
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
|
||||
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
|
||||
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
|
||||
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
|
||||
CREATE TABLE IF NOT EXISTS wishes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
|
||||
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
|
||||
admin_note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
|
||||
@@ -119,6 +119,22 @@ function parentAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям
|
||||
* ученика (student/free_student); учитель и админ проходят всегда.
|
||||
* Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент,
|
||||
* материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать
|
||||
* доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false).
|
||||
*/
|
||||
function requirePermissionForStudents(key) {
|
||||
const guard = requirePermission(key);
|
||||
return (req, res, next) => {
|
||||
const r = req.user?.role;
|
||||
if (r === 'student' || r === 'free_student') return guard(req, res, next);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/* Alias: requireAuth = authMiddleware */
|
||||
const requireAuth = authMiddleware;
|
||||
|
||||
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
|
||||
|
||||
@@ -115,6 +115,28 @@ const PERMISSIONS = {
|
||||
label: 'Управление геймификацией',
|
||||
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
|
||||
},
|
||||
'classroom.host': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Вести онлайн-уроки',
|
||||
desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'livequiz.host': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Запускать живые викторины',
|
||||
desc: 'Создавать и проводить синхронные викторины в реальном времени',
|
||||
},
|
||||
'simbuilder.use': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Конструктор симуляций',
|
||||
desc: 'Создавать и редактировать собственные интерактивные симуляции',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'flashcards.manage': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Общие колоды флеш-карт',
|
||||
desc: 'Создавать и раздавать общие колоды флеш-карт классам',
|
||||
},
|
||||
|
||||
/* ── Student (also applies to free_student — same keys, same defaults) ── */
|
||||
'tests.free': {
|
||||
@@ -160,6 +182,38 @@ const PERMISSIONS = {
|
||||
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
||||
requires: ['simulations.access'],
|
||||
},
|
||||
'homework.submit': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Сдавать домашние задания',
|
||||
desc: 'Загружать работы и пересдавать домашние задания',
|
||||
},
|
||||
'materials.save': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Сохранять материалы',
|
||||
desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»',
|
||||
},
|
||||
'assistant.use': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'ИИ-ассистент',
|
||||
desc: 'Задавать вопросы ИИ-ассистенту «Квантик»',
|
||||
},
|
||||
'flashcards.access': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Раздел флеш-карт доступен роли',
|
||||
desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'exam.access': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Подготовка к экзаменам доступна роли',
|
||||
desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'games.play': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Учебные игры',
|
||||
desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)',
|
||||
},
|
||||
};
|
||||
|
||||
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
|
||||
@@ -169,15 +223,20 @@ const GROUP = {
|
||||
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
|
||||
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
|
||||
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
|
||||
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
|
||||
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
|
||||
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
|
||||
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
|
||||
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
|
||||
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
|
||||
// student
|
||||
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
|
||||
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
|
||||
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
|
||||
'profile.edit': 'Профиль',
|
||||
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
|
||||
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
|
||||
'flashcards.access': 'Контент', 'exam.access': 'Контент',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,10 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
|
||||
/* Everything below is admin-only */
|
||||
router.use(requireRole('admin'));
|
||||
|
||||
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
|
||||
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
|
||||
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
|
||||
|
||||
router.get('/assistant', ctrl.getAssistant);
|
||||
router.put('/assistant', ctrl.saveAssistant);
|
||||
router.post('/assistant/test', ctrl.testAssistant);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
|
||||
* 'pet' навешивается при монтировании в server.js. */
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const ctrl = require('../controllers/assistantController');
|
||||
|
||||
@@ -16,8 +16,8 @@ router.get('/context', ctrl.getContext);
|
||||
router.post('/seen', ctrl.markSeen);
|
||||
router.post('/dismiss', ctrl.dismiss);
|
||||
router.patch('/settings', ctrl.setSettings);
|
||||
router.post('/ask', askLimiter, ctrl.ask);
|
||||
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
|
||||
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
|
||||
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
|
||||
router.post('/feedback', ctrl.feedback);
|
||||
router.get('/memory', ctrl.getMemory);
|
||||
router.delete('/memory', ctrl.clearMemory);
|
||||
|
||||
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
|
||||
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
|
||||
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
|
||||
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
|
||||
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
|
||||
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
|
||||
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
|
||||
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
|
||||
|
||||
@@ -2,7 +2,7 @@ const router = require('express').Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const c = require('../controllers/classroomController');
|
||||
|
||||
@@ -47,7 +47,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
|
||||
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
||||
|
||||
// Session lifecycle
|
||||
router.post('/', ...teacher, c.createSession);
|
||||
router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
|
||||
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
||||
router.get('/my/session', ...auth, c.getMySession);
|
||||
router.get('/class/:classId/active', ...auth, c.getActiveSession);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const ctrl = require('../controllers/clientErrorController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
|
||||
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,7 +5,7 @@
|
||||
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const { requireFeature } = require('../middleware/features');
|
||||
const c = require('../controllers/customSimController');
|
||||
|
||||
@@ -22,9 +22,9 @@ router.get('/:id', c.get);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id/related', c.related);
|
||||
|
||||
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
|
||||
router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
|
||||
router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
|
||||
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
|
||||
router.use(requirePermissionForStudents('exam.access'));
|
||||
|
||||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
@@ -57,6 +60,10 @@ const VARIANT_LABEL = {
|
||||
115: 'ЦТ-2019',
|
||||
116: 'ЦТ-2020',
|
||||
117: 'ЦТ-2021',
|
||||
118: 'ЦТ-2017',
|
||||
119: 'ЦТ-2013',
|
||||
120: 'ЦТ-2012',
|
||||
121: 'ЦТ-2011',
|
||||
},
|
||||
};
|
||||
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
|
||||
|
||||
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const fc = require('../controllers/flashcardController');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const { requireOwnership } = require('../middleware/ownership');
|
||||
|
||||
/* ── multer для картинок карточек ───────────────────────────────────────
|
||||
@@ -30,6 +30,9 @@ const fcUpload = multer({
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
|
||||
// учитель/админ проходят всегда (создают и раздают колоды).
|
||||
router.use(requirePermissionForStudents('flashcards.access'));
|
||||
|
||||
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
|
||||
|
||||
@@ -45,8 +48,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
||||
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
||||
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
|
||||
router.get ('/decks/:id/shares', fc.listShares);
|
||||
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
|
||||
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
|
||||
router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
|
||||
router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
|
||||
router.get ('/decks/:id/study', fc.getStudySession);
|
||||
router.put ('/cards/:id', fc.updateCard);
|
||||
router.delete('/cards/:id', fc.deleteCard);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const { requireFeature } = require('../middleware/features');
|
||||
const c = require('../controllers/gamesController');
|
||||
|
||||
const hangman = requireFeature('hangman');
|
||||
const crossword = requireFeature('crossword');
|
||||
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
|
||||
const playable = requirePermissionForStudents('games.play');
|
||||
|
||||
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord);
|
||||
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete);
|
||||
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate);
|
||||
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete);
|
||||
router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
|
||||
router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
|
||||
router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
|
||||
router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const c = require('../controllers/liveController');
|
||||
|
||||
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
|
||||
|
||||
router.post('/', ...teacher, c.create);
|
||||
router.post('/', ...teacher, requirePermission('livequiz.host'), c.create);
|
||||
router.get('/:id', ...teacher, c.getSession);
|
||||
router.put('/:id/question', ...teacher, c.setQuestion);
|
||||
router.get('/:id/results', ...teacher, c.results);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const c = require('../controllers/studentMaterialsController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
@@ -10,7 +10,8 @@ router.use(authMiddleware);
|
||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||
|
||||
router.get('/', c.list);
|
||||
router.post('/', c.create);
|
||||
// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
|
||||
router.post('/', requirePermissionForStudents('materials.save'), c.create);
|
||||
|
||||
// Collections (folders) — literal '/collections' prefix before '/:id'
|
||||
router.post('/collections', c.createCollection);
|
||||
|
||||
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
|
||||
|
||||
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
|
||||
const { default_mode, default_count, default_test_id } = req.body;
|
||||
const valid_modes = ['exam', 'practice', 'topic', 'random'];
|
||||
// Старт сессии (POST /api/sessions) поддерживает только exam/practice — раньше тут
|
||||
// допускались topic/random, но клик по такому предмету на дашборде падал с 400.
|
||||
const valid_modes = ['exam', 'practice'];
|
||||
if (default_mode && !valid_modes.includes(default_mode))
|
||||
return res.status(400).json({ error: 'Invalid mode' });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const router = require('express').Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/submissionsController');
|
||||
const { fixUtf8Name } = require('../utils/fixUtf8');
|
||||
|
||||
@@ -47,7 +47,7 @@ const upload = multer({
|
||||
/* ── routes ─────────────────────────────────────────────────────────── */
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
|
||||
router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit);
|
||||
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
|
||||
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
|
||||
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
|
||||
@@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm
|
||||
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
|
||||
router.get('/:id/download', ctrl.downloadSubmission);
|
||||
router.delete('/:id', ctrl.deleteSubmission);
|
||||
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
|
||||
router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/wishController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
|
||||
router.post('/', ctrl.create); // любой авторизованный
|
||||
|
||||
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
|
||||
router.patch('/:id', requireRole('admin'), ctrl.update);
|
||||
router.delete('/:id', ctrl.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -198,6 +198,8 @@ app.use('/api/lab', labRoutes);
|
||||
app.use('/api/materials', require('./routes/materials'));
|
||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||
app.use('/api/game', require('./routes/game'));
|
||||
app.use('/api/wishes', require('./routes/wishes'));
|
||||
app.use('/api/client-errors', require('./routes/clientErrors'));
|
||||
app.use('/api/prep', require('./routes/prep'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
systemReset.js — общая логика «чистого запуска» (используют и CLI
|
||||
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
|
||||
|
||||
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
|
||||
|
||||
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
|
||||
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
|
||||
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
|
||||
const REASSIGN = {
|
||||
courses: 'created_by', tests: 'created_by',
|
||||
flashcard_decks: 'user_id', custom_sims: 'owner_id',
|
||||
course_templates: 'created_by', lesson_templates: 'created_by',
|
||||
assignment_templates: 'created_by', lab_sim_links: 'created_by',
|
||||
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
|
||||
};
|
||||
|
||||
/* Активность/организация — полностью очищается. */
|
||||
const WIPE = new Set([
|
||||
'test_sessions', 'session_questions', 'user_answers',
|
||||
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
|
||||
'assignments', 'assignment_sessions', 'assignment_completion',
|
||||
'submissions', 'submission_log',
|
||||
'classes', 'class_members', 'class_courses',
|
||||
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
|
||||
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
|
||||
'classroom_notes', 'classroom_pages', 'classroom_strokes',
|
||||
'live_sessions', 'live_answers',
|
||||
'content_access',
|
||||
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
|
||||
'notifications', 'parent_notifications', 'parent_links',
|
||||
'student_materials', 'material_collections',
|
||||
'game_progress',
|
||||
'lesson_progress', 'lesson_comments', 'lesson_notes',
|
||||
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
|
||||
'flashcard_reviews', 'flashcard_deck_access',
|
||||
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
|
||||
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
|
||||
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
|
||||
'imggen_usage',
|
||||
'folder_access', 'file_access',
|
||||
'avatar_requests',
|
||||
'geometry_submissions', 'geometry_tasks',
|
||||
'security_events', 'error_log', 'admin_audit_log',
|
||||
'student_prep',
|
||||
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
|
||||
]);
|
||||
|
||||
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
|
||||
const KEEP = new Set([
|
||||
'subjects', 'questions', 'options', 'topics',
|
||||
'textbooks', 'textbook_chunks',
|
||||
'lessons', 'lesson_blocks', 'course_sections',
|
||||
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
|
||||
'test_questions', 'flashcard_cards', 'lab_sims',
|
||||
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
|
||||
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
|
||||
'shop_items', 'achievements',
|
||||
'roles', 'role_permissions', 'app_settings', '_migrations',
|
||||
]);
|
||||
|
||||
const ADMIN_RESET_SQL =
|
||||
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
|
||||
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
|
||||
pet_petting_streak = 0 WHERE id = ?`;
|
||||
|
||||
function allTables(db) {
|
||||
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all().map(r => r.name);
|
||||
}
|
||||
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
|
||||
|
||||
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
|
||||
function pickKeptAdmin(db) {
|
||||
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
|
||||
}
|
||||
|
||||
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
|
||||
function classify(db) {
|
||||
const tables = allTables(db);
|
||||
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
|
||||
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
|
||||
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
|
||||
const totalUsers = rowCount(db, 'users');
|
||||
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
|
||||
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
|
||||
* которого сохраняем (ему переназначается контент). Возвращает сводку.
|
||||
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
|
||||
*/
|
||||
function runReset(db, keptAdminId) {
|
||||
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
|
||||
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [t, col] of Object.entries(REASSIGN)) {
|
||||
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
|
||||
}
|
||||
for (const t of WIPE) {
|
||||
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
|
||||
}
|
||||
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
|
||||
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
|
||||
db.exec('COMMIT');
|
||||
var deletedUsers = del.changes;
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
throw e;
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
let fkBad = 0;
|
||||
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
|
||||
try { db.exec('VACUUM'); } catch {}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
keptAdminId,
|
||||
deletedUsers,
|
||||
remainingUsers: rowCount(db, 'users'),
|
||||
fkDangling: fkBad,
|
||||
kept: {
|
||||
textbooks: rowCount(db, 'textbooks'),
|
||||
questions: rowCount(db, 'questions'),
|
||||
tests: rowCount(db, 'tests'),
|
||||
courses: rowCount(db, 'courses'),
|
||||
exam_tasks: rowCount(db, 'exam_tasks'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, db, cleanup } = require('./setup');
|
||||
|
||||
app.use('/api/client-errors', require('../src/routes/clientErrors'));
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/client-errors', () => {
|
||||
let student;
|
||||
before(async () => { student = await getToken('student'); });
|
||||
|
||||
it('требует авторизацию (401)', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('пустое сообщение → 400', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('пишет ошибку в error_log с level=client', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
kind: 'error', message: 'TypeError: x is null',
|
||||
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
|
||||
url: '/lab?sim=demo#x',
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
|
||||
).get(student.userId);
|
||||
assert.ok(row, 'строка должна появиться');
|
||||
assert.equal(row.message, 'TypeError: x is null');
|
||||
assert.equal(row.route, '/lab?sim=demo#x');
|
||||
assert.equal(row.method, 'error');
|
||||
assert.match(row.stack, /app\.js:10:5/);
|
||||
});
|
||||
|
||||
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
|
||||
).get();
|
||||
assert.equal(row.method, 'rejection');
|
||||
assert.match(row.stack, /x\.js:3:1/);
|
||||
});
|
||||
|
||||
it('длинные поля обрезаются (не падает)', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
|
||||
assert.ok(row.message.length <= 1000);
|
||||
assert.ok(row.stack.length <= 4000);
|
||||
assert.ok(row.route.length <= 400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
|
||||
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
|
||||
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
|
||||
* (автор «новое» / админ; чужое нельзя).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
app.use('/api/wishes', require('../src/routes/wishes'));
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/wishes', () => {
|
||||
let s1, s2, admin;
|
||||
|
||||
before(async () => {
|
||||
s1 = await getToken('student');
|
||||
s2 = await getToken('student');
|
||||
admin = await getToken('admin');
|
||||
});
|
||||
|
||||
it('POST /wishes requires auth (401)', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('создание: пустой заголовок → 400', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
let wishId;
|
||||
it('создание пожелания учеником → 201, статус new', async () => {
|
||||
const res = await inject('POST', '/api/wishes',
|
||||
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'new');
|
||||
assert.equal(res.body.category, 'ui');
|
||||
assert.equal(res.body.user_id, s1.userId);
|
||||
wishId = res.body.id;
|
||||
});
|
||||
|
||||
it('неизвестная категория → other', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
|
||||
assert.equal(res.body.category, 'other');
|
||||
});
|
||||
|
||||
it('приватность: автор видит свои', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.equal(res.body.isAdmin, false);
|
||||
});
|
||||
|
||||
it('приватность: другой ученик НЕ видит чужое', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s2.token);
|
||||
assert.ok(!res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('админ видит все + counts', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.isAdmin, true);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
|
||||
// у админа в списке есть имя автора
|
||||
const w = res.body.wishes.find(x => x.id === wishId);
|
||||
assert.ok(w.author_name);
|
||||
});
|
||||
|
||||
it('триаж учеником запрещён (403)', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('админ меняет статус + ответ → 200', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`,
|
||||
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'planned');
|
||||
assert.equal(res.body.admin_note, 'Запланировано на лето');
|
||||
});
|
||||
|
||||
it('неверный статус → 400', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('фильтр по статусу у админа', async () => {
|
||||
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
|
||||
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('чужой ученик не может удалить → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('автор удаляет своё «новое» → 200', async () => {
|
||||
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
|
||||
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it('админ удаляет любое → 200', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
const gone = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
});
|
||||
+11
-4
@@ -486,6 +486,13 @@
|
||||
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
|
||||
.tst-search:focus { border-color: var(--violet); }
|
||||
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
|
||||
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
|
||||
.tst-pick-sel:focus { border-color: var(--violet); }
|
||||
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
|
||||
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
|
||||
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
|
||||
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
|
||||
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
/* formula bar */
|
||||
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
|
||||
@@ -1071,7 +1078,7 @@
|
||||
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
|
||||
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
|
||||
<i data-lucide="layout-grid" style="width:15px;height:15px"></i> Модули
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
||||
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
|
||||
@@ -1568,10 +1575,10 @@
|
||||
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Игры ── -->
|
||||
<!-- ── Модули ── -->
|
||||
<div class="tab-pane" id="tab-games">
|
||||
<div class="section-title">Управление играми</div>
|
||||
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
||||
<div class="section-title">Управление модулями</div>
|
||||
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
||||
<div class="perm-grid" id="games-features-grid">
|
||||
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
||||
</div>
|
||||
|
||||
+127
-1
@@ -109,6 +109,29 @@
|
||||
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||||
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||||
|
||||
/* ── Долги (что висит у учеников) ── */
|
||||
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
|
||||
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
|
||||
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
|
||||
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
|
||||
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.debt-name { font-size: 0.9rem; font-weight: 700; }
|
||||
.debt-email { font-size: 0.74rem; color: var(--text-3); }
|
||||
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
|
||||
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
|
||||
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
|
||||
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
|
||||
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
|
||||
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
|
||||
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
|
||||
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
|
||||
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
|
||||
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
|
||||
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||||
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
|
||||
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
|
||||
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
|
||||
|
||||
/* ── Student search ── */
|
||||
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
|
||||
.student-search-wrap .form-input { width: 100%; }
|
||||
@@ -628,6 +651,7 @@
|
||||
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
|
||||
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
|
||||
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
|
||||
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
|
||||
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
|
||||
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
|
||||
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
|
||||
@@ -667,6 +691,11 @@
|
||||
<div class="assign-list" id="d-assignments"></div>
|
||||
</div>
|
||||
|
||||
<!-- Debts (что висит у учеников) -->
|
||||
<div class="tab-pane" id="dtab-debts">
|
||||
<div id="debts-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Journal -->
|
||||
<div class="tab-pane" id="dtab-journal">
|
||||
<div id="journal-content"><div class="spinner"></div></div>
|
||||
@@ -794,7 +823,7 @@
|
||||
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
|
||||
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
|
||||
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
|
||||
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
|
||||
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Загрузка работы</button>
|
||||
</div>
|
||||
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
|
||||
Вопросы подбираются случайно из базы по выбранному предмету
|
||||
@@ -1199,6 +1228,102 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
|
||||
const DEBT_STATUS = {
|
||||
overdue: { label: 'просрочено', cls: 'dc-overdue' },
|
||||
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
|
||||
revision: { label: 'на доработке', cls: 'dc-revision' },
|
||||
not_started: { label: 'не начато', cls: 'dc-not_started' },
|
||||
};
|
||||
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
|
||||
let _debtData = null;
|
||||
|
||||
async function loadDebts() {
|
||||
if (!currentClass) return;
|
||||
const el = document.getElementById('debts-content');
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
_debtData = await LS.classOutstanding(currentClass.id);
|
||||
renderDebts();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function debtChips(c) {
|
||||
return ['overdue', 'in_progress', 'revision', 'not_started']
|
||||
.filter(k => c[k] > 0)
|
||||
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function debtItemHtml(s, p) {
|
||||
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
|
||||
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
|
||||
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
|
||||
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
|
||||
return `<div class="debt-item">
|
||||
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="debt-item-title">${esc(p.title)}</div>
|
||||
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
|
||||
</div>
|
||||
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDebts() {
|
||||
const el = document.getElementById('debts-content');
|
||||
if (!_debtData) { el.innerHTML = ''; return; }
|
||||
const { summary, students } = _debtData;
|
||||
const withDebt = students.filter(s => s.counts.total > 0);
|
||||
const badge = document.getElementById('debts-tab-badge');
|
||||
if (badge) {
|
||||
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
|
||||
else badge.style.display = 'none';
|
||||
}
|
||||
if (!withDebt.length) {
|
||||
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
let html = `<div class="debt-summary">
|
||||
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
|
||||
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
|
||||
</div>`;
|
||||
html += withDebt.map(s => `
|
||||
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
|
||||
<div class="debt-head">
|
||||
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
|
||||
<div class="debt-chips">${debtChips(s.counts)}</div>
|
||||
</div>
|
||||
${s.pending.map(p => debtItemHtml(s, p)).join('')}
|
||||
</div>`).join('');
|
||||
const clear = summary.students_total - withDebt.length;
|
||||
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
|
||||
el.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function deleteDebtAssignment(id, scope, studentId) {
|
||||
let title = 'задание', studentName = '';
|
||||
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
|
||||
if (stu) {
|
||||
studentName = stu.name;
|
||||
const it = stu.pending.find(p => p.assignment_id === id);
|
||||
if (it) title = it.title;
|
||||
}
|
||||
const msg = scope === 'direct'
|
||||
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
|
||||
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
|
||||
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.deleteAssignment(id);
|
||||
LS.toast('Задание удалено', 'info');
|
||||
await loadDebts();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
/* ══ Detail tabs ══ */
|
||||
function switchDetailTab(btn) {
|
||||
const name = btn.dataset.tab;
|
||||
@@ -1208,6 +1333,7 @@
|
||||
document.getElementById('dtab-' + name).classList.add('active');
|
||||
if (name === 'announce') loadAnnouncements();
|
||||
if (name === 'dash') loadClassDashboard();
|
||||
if (name === 'debts') loadDebts();
|
||||
if (name === 'journal') loadJournal();
|
||||
if (name === 'settings') loadSettings();
|
||||
if (name === 'works') loadClassWorks();
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
LS.sidebar?.init();
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures();
|
||||
if (feats.collection === false) { window.location.replace('/403'); return; }
|
||||
if (feats.collection === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
await loadCollection();
|
||||
})();
|
||||
|
||||
+27
-16
@@ -1039,7 +1039,7 @@ body {
|
||||
body.no-class #lb-section { display: none !important; }
|
||||
|
||||
/* Gamification kill-switch.
|
||||
When admin turns off the feature, body.no-gamification is set by
|
||||
When admin turns off the feature, .no-gamification is set by
|
||||
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
|
||||
achievement / frame element must vanish — across the whole app,
|
||||
not just the dashboard. The rules below cover:
|
||||
@@ -1050,21 +1050,32 @@ body.no-class #lb-section { display: none !important; }
|
||||
• a catch-all [data-gamified] hook that wraps any future block —
|
||||
authors of new pages should wrap XP UI in a <div data-gamified>
|
||||
instead of inventing new classes. */
|
||||
body.no-gamification .gam-bar,
|
||||
body.no-gamification .lb-widget,
|
||||
body.no-gamification .achievements-section,
|
||||
body.no-gamification #tab-btn-achievements,
|
||||
body.no-gamification #tab-btn-shop,
|
||||
body.no-gamification #tab-achievements,
|
||||
body.no-gamification #tab-shop,
|
||||
body.no-gamification #frames-section,
|
||||
body.no-gamification .hero-xp-badge,
|
||||
body.no-gamification .po-xp,
|
||||
body.no-gamification .xp-card,
|
||||
body.no-gamification .xp-bar,
|
||||
body.no-gamification .xp-pill,
|
||||
body.no-gamification .xp-badge,
|
||||
body.no-gamification [data-gamified] { display: none !important; }
|
||||
.no-gamification .gam-bar,
|
||||
.no-gamification .lb-widget,
|
||||
.no-gamification .achievements-section,
|
||||
.no-gamification #tab-btn-achievements,
|
||||
.no-gamification #tab-btn-shop,
|
||||
.no-gamification #tab-achievements,
|
||||
.no-gamification #tab-shop,
|
||||
.no-gamification #frames-section,
|
||||
.no-gamification .hero-xp-badge,
|
||||
.no-gamification .po-xp,
|
||||
.no-gamification .xp-card,
|
||||
.no-gamification .xp-bar,
|
||||
.no-gamification .xp-pill,
|
||||
.no-gamification .xp-badge,
|
||||
/* challenges / еженедельные испытания (dashboard) */
|
||||
.no-gamification .ch-widget,
|
||||
.no-gamification #ch-section,
|
||||
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
|
||||
.no-gamification .streak-cal,
|
||||
.no-gamification #sr-streak,
|
||||
.no-gamification .hc-pet .chip-streak,
|
||||
.no-gamification .hc-pet .chip-goal,
|
||||
/* монеты (профиль) и xp-прогресс */
|
||||
.no-gamification #p-coins-row,
|
||||
.no-gamification .gam-progress,
|
||||
.no-gamification [data-gamified] { display: none !important; }
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
RESPONSIVE — SMALL PHONES (≤ 480px)
|
||||
|
||||
+148
-24
@@ -81,7 +81,33 @@
|
||||
}
|
||||
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
||||
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
||||
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||
|
||||
/* ── Live online-lesson banner ── */
|
||||
.live-lesson {
|
||||
display: flex; align-items: center; gap: 14px; text-decoration: none;
|
||||
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
|
||||
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
|
||||
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
|
||||
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
|
||||
@keyframes llPulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
|
||||
}
|
||||
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
|
||||
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
|
||||
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
|
||||
@media (max-width: 480px) {
|
||||
.live-lesson { padding: 12px 14px; gap: 10px; }
|
||||
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
|
||||
}
|
||||
.hero-card {
|
||||
position: relative; border-radius: 18px; padding: 18px 20px;
|
||||
display: flex; flex-direction: column; min-height: 196px;
|
||||
@@ -1532,6 +1558,13 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Live online-lesson status (student/teacher) -->
|
||||
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
|
||||
<span class="ll-dot"></span>
|
||||
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
|
||||
<span class="ll-cta" id="ll-cta">Присоединиться</span>
|
||||
</a>
|
||||
|
||||
<!-- Gamification Bar (students only) -->
|
||||
<div class="gam-bar" id="gam-bar" style="display:none">
|
||||
<div class="gam-level">
|
||||
@@ -1750,6 +1783,11 @@
|
||||
<div class="widget" id="w-tests">
|
||||
<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 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>
|
||||
|
||||
<!-- Col 3: Progress -->
|
||||
@@ -1884,6 +1922,7 @@
|
||||
<!-- Join modal -->
|
||||
<!-- Quick-start test modal -->
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script src="/js/sound.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
@@ -2222,10 +2261,14 @@
|
||||
async function loadSubjects() {
|
||||
const list = document.getElementById('subjects-list');
|
||||
try {
|
||||
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' };
|
||||
const subjects = await LS.getSubjects();
|
||||
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
|
||||
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
|
||||
const subjects = (await LS.getSubjects())
|
||||
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
|
||||
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
|
||||
list.innerHTML = subjects.map((s, si) => {
|
||||
const mode = s.default_mode || 'exam';
|
||||
let mode = s.default_mode || 'exam';
|
||||
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
|
||||
const count = s.default_count || 25;
|
||||
const testId = s.default_test_id || null;
|
||||
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
|
||||
@@ -2253,6 +2296,32 @@
|
||||
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() {
|
||||
try {
|
||||
@@ -2346,15 +2415,8 @@
|
||||
body.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
/* ── Urgency sort score (lower = shown first) ── */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4; // in progress <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> top
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
if (dlMs < 0) return -3; // overdue
|
||||
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
|
||||
if (dlMs < Infinity) return dlMs; // sorted by deadline
|
||||
return 1e12; // no deadline <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> last
|
||||
}
|
||||
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
/* ── Is assignment urgent for teacher (within 48h) ── */
|
||||
function isTeacherUrgent(a) {
|
||||
@@ -2422,7 +2484,7 @@
|
||||
}
|
||||
|
||||
/* ── Upload-only homework (no test, no file) ── */
|
||||
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) {
|
||||
if (AssignmentUtils.type(a) === 'upload') {
|
||||
const over = a.deadline && new Date(a.deadline) < new Date();
|
||||
const sub = _mySubmissions.get(a.id);
|
||||
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
||||
@@ -2659,18 +2721,22 @@
|
||||
reIcons(); return;
|
||||
}
|
||||
|
||||
// Classify
|
||||
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
|
||||
function classify(a) {
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const usedAtt = a.attempts_used ?? 0;
|
||||
if (a.textbook_id) {
|
||||
if (a.completed_at || a.textbook_all_read) return 'done';
|
||||
const t = AssignmentUtils.type(a);
|
||||
if (t === 'textbook') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
|
||||
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
|
||||
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
if (t === 'test') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
|
||||
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
|
||||
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
@@ -3670,12 +3736,36 @@
|
||||
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
|
||||
}
|
||||
|
||||
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
|
||||
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
|
||||
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
|
||||
function syncProgressCol() {
|
||||
const col = document.getElementById('w-progress-col');
|
||||
if (!col) return;
|
||||
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
|
||||
const e = document.getElementById(id);
|
||||
return e && getComputedStyle(e).display !== 'none';
|
||||
});
|
||||
col.style.display = any ? '' : 'none';
|
||||
}
|
||||
|
||||
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
|
||||
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
|
||||
function syncHeroRow() {
|
||||
const row = document.getElementById('hero-row');
|
||||
if (!row) return;
|
||||
const vis = [...row.querySelectorAll('.hero-card')]
|
||||
.filter(c => getComputedStyle(c).display !== 'none');
|
||||
row.style.display = vis.length ? '' : 'none';
|
||||
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
|
||||
function loadLastResultsWidget(rows) {
|
||||
const w = document.getElementById('w-last-results');
|
||||
if (!w) return;
|
||||
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
|
||||
if (!completed.length) { w.style.display = 'none'; return; }
|
||||
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
||||
w.style.display = '';
|
||||
document.getElementById('last-results-list').innerHTML = completed.map(h => {
|
||||
const pct = Math.round(h.score / h.total * 100);
|
||||
@@ -3689,6 +3779,7 @@
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
syncProgressCol();
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
|
||||
@@ -3702,7 +3793,7 @@
|
||||
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
|
||||
});
|
||||
const entries = Object.entries(bySubj);
|
||||
if (!entries.length) { w.style.display = 'none'; return; }
|
||||
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
||||
w.style.display = '';
|
||||
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
|
||||
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
|
||||
@@ -3713,6 +3804,7 @@
|
||||
<span class="sp-pct" style="color:${color}">${avg}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
syncProgressCol();
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
|
||||
@@ -4285,6 +4377,7 @@
|
||||
loadLabOfDay();
|
||||
loadPetHero();
|
||||
loadFlashcardWidget();
|
||||
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
||||
@@ -4298,6 +4391,7 @@
|
||||
renderFlashcardWidget(r);
|
||||
w.style.display = '';
|
||||
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
||||
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
|
||||
}
|
||||
|
||||
function renderFlashcardWidget(r) {
|
||||
@@ -4473,6 +4567,7 @@
|
||||
} else {
|
||||
// Student: full layout
|
||||
loadSubjects();
|
||||
loadAvailableTests();
|
||||
loadAssignments();
|
||||
loadStats();
|
||||
loadGamification();
|
||||
@@ -4481,13 +4576,42 @@
|
||||
loadDashboardStats();
|
||||
applyDashboardPrefs();
|
||||
}
|
||||
loadLiveLesson();
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
|
||||
LS.notif.init();
|
||||
|
||||
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
|
||||
async function loadLiveLesson() {
|
||||
const el = document.getElementById('live-lesson-banner');
|
||||
if (!el) return;
|
||||
let data;
|
||||
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
|
||||
const s = data && data.session;
|
||||
if (!s) { el.style.display = 'none'; return; }
|
||||
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
|
||||
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
|
||||
let sub;
|
||||
if (isTeacher) {
|
||||
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
|
||||
sub = online ? (online + ' онлайн') : 'ожидание учеников';
|
||||
} else {
|
||||
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
|
||||
}
|
||||
document.getElementById('ll-sub').textContent = sub;
|
||||
document.getElementById('ll-cta').textContent = isTeacher
|
||||
? 'Вернуться к доске'
|
||||
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
// Real-time SSE for page-specific events (notif handled by notifications.js)
|
||||
LS.connectSSE(ev => {
|
||||
if (ev.type === 'assignment') {
|
||||
LS.toast(ev.message, 'info');
|
||||
isTeacher ? loadAdminAssignments() : loadAssignments();
|
||||
} else if (ev.type === 'classroom_live') {
|
||||
loadLiveLesson();
|
||||
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
|
||||
} else if (ev.type === 'session') {
|
||||
LS.toast(ev.message, 'info');
|
||||
if (isTeacher) loadAdminSessions();
|
||||
|
||||
+228
-22
@@ -125,6 +125,41 @@
|
||||
/* student name in teacher view */
|
||||
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
|
||||
|
||||
/* ── section titles (multi-block student view) ── */
|
||||
.hw-sec-title {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
||||
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
|
||||
}
|
||||
.hw-sec-count {
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
|
||||
color: var(--violet); background: rgba(155,93,229,0.1);
|
||||
padding: 2px 9px; border-radius: 999px;
|
||||
}
|
||||
#hw-active-wrap { margin-bottom: 28px; }
|
||||
|
||||
/* ── active assignment cards ── */
|
||||
.hw-acard {
|
||||
background: #fff; border-radius: 16px; padding: 16px 18px;
|
||||
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
|
||||
display: flex; align-items: center; gap: 14px; transition: all .15s;
|
||||
}
|
||||
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
|
||||
.hw-acard.over { border-left-color: #EF476F; }
|
||||
.hw-acard.urgent { border-left-color: #F59E0B; }
|
||||
.hw-acard-icon {
|
||||
width: 42px; height: 42px; border-radius: 12px; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.hw-acard-body { flex: 1; min-width: 0; }
|
||||
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
|
||||
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
|
||||
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
|
||||
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
|
||||
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 16px 14px 80px; }
|
||||
.hw-top { gap: 8px; }
|
||||
@@ -133,6 +168,8 @@
|
||||
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
|
||||
.hw-card-actions { flex-wrap: wrap; }
|
||||
.hw-upload-area { padding: 20px 16px; }
|
||||
.hw-acard { flex-wrap: wrap; }
|
||||
.hw-acard-right { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 12px 10px 80px; }
|
||||
@@ -152,8 +189,15 @@
|
||||
<div class="page-title">Домашние задания</div>
|
||||
<div class="page-sub" id="hw-sub">Загрузка…</div>
|
||||
|
||||
<!-- Student: active assignments (что нужно сделать) -->
|
||||
<div id="hw-active-wrap" style="display:none">
|
||||
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
|
||||
<div class="hw-list" id="hw-active-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Student: upload area -->
|
||||
<div id="hw-upload-wrap" style="display:none">
|
||||
<div class="hw-sec-title">Сдать работу</div>
|
||||
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
|
||||
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
|
||||
<div class="hw-upload-text">Загрузить работу</div>
|
||||
@@ -195,6 +239,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Student: status filters -->
|
||||
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
|
||||
<div class="hw-top" id="hw-top-student" style="display:none">
|
||||
<div class="hw-status-filters">
|
||||
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
|
||||
@@ -213,6 +258,7 @@
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script>
|
||||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
@@ -247,6 +293,14 @@
|
||||
resubmitted: 'Повторно', accepted: 'Принято'
|
||||
};
|
||||
|
||||
/* subject label/colour/icon maps (как на дашборде) */
|
||||
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
||||
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
|
||||
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
|
||||
|
||||
let _assignments = []; // актуальные задания (LS.myAssignments)
|
||||
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
|
||||
|
||||
/* ── filter ── */
|
||||
function filterStatus(st, btn) {
|
||||
_statusFilter = st;
|
||||
@@ -257,33 +311,31 @@
|
||||
|
||||
/* ── STUDENT VIEW ── */
|
||||
async function initStudent() {
|
||||
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
|
||||
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
|
||||
document.getElementById('hw-top-student').style.display = '';
|
||||
document.getElementById('hw-mysubs-title').style.display = '';
|
||||
|
||||
// Find student's class
|
||||
// Find student's class (нужен для загрузки работ без привязки к заданию)
|
||||
try {
|
||||
const classes = await LS.myClasses();
|
||||
if (classes.length) {
|
||||
_studentClassId = classes[0].id;
|
||||
document.getElementById('hw-upload-wrap').style.display = '';
|
||||
|
||||
// Load assignments for selector
|
||||
try {
|
||||
const feed = await LS.classFeed(classes[0].id);
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
(feed.assignments || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.title;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Load submissions
|
||||
// Грузим актуальные задания (все классы) + сдачи параллельно
|
||||
try {
|
||||
_submissions = await LS.getMySubmissions();
|
||||
const [assigns, subs] = await Promise.all([
|
||||
LS.myAssignments().catch(() => []),
|
||||
LS.getMySubmissions().catch(() => []),
|
||||
]);
|
||||
_assignments = Array.isArray(assigns) ? assigns : [];
|
||||
_submissions = Array.isArray(subs) ? subs : [];
|
||||
_subByAsgn.clear();
|
||||
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
|
||||
populateAssignmentSelect(_assignments);
|
||||
renderActiveAssignments();
|
||||
renderSubmissions();
|
||||
} catch {
|
||||
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
|
||||
@@ -312,14 +364,22 @@
|
||||
}
|
||||
|
||||
async function submitHomework() {
|
||||
if (!_selectedFile || !_studentClassId) return;
|
||||
if (!_selectedFile) return;
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
const assignId = sel.value;
|
||||
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
|
||||
// иначе — первый класс ученика.
|
||||
let classId = _studentClassId;
|
||||
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
|
||||
classId = sel.selectedOptions[0].dataset.class;
|
||||
}
|
||||
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
|
||||
const btn = document.getElementById('hw-submit-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', _selectedFile);
|
||||
fd.append('class_id', _studentClassId);
|
||||
const assignId = document.getElementById('hw-assignment-sel').value;
|
||||
fd.append('class_id', classId);
|
||||
if (assignId) fd.append('assignment_id', assignId);
|
||||
const msg = document.getElementById('hw-message').value.trim();
|
||||
if (msg) fd.append('message', msg);
|
||||
@@ -336,12 +396,20 @@
|
||||
|
||||
// Reload
|
||||
_submissions = await LS.getMySubmissions();
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
} catch (e) {
|
||||
LS.toast(e.message || 'Ошибка отправки', 'error');
|
||||
} finally { btn.disabled = !_selectedFile; }
|
||||
}
|
||||
|
||||
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
|
||||
function syncStudentLists() {
|
||||
_subByAsgn.clear();
|
||||
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
|
||||
renderActiveAssignments();
|
||||
renderSubmissions();
|
||||
}
|
||||
|
||||
async function resubmitHomework(subId) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
@@ -355,7 +423,7 @@
|
||||
await LS.resubmitWork(subId, fd);
|
||||
LS.toast('Работа отправлена повторно!', 'success');
|
||||
_submissions = await LS.getMySubmissions();
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
};
|
||||
input.click();
|
||||
@@ -366,7 +434,7 @@
|
||||
try {
|
||||
await LS.deleteSubmission(id);
|
||||
_submissions = _submissions.filter(s => s.id !== id);
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
LS.toast('Удалено', 'info');
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
@@ -381,6 +449,144 @@
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
|
||||
|
||||
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
|
||||
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
|
||||
function asgnType(a) { return AssignmentUtils.type(a); }
|
||||
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
function deadlineChip(a) {
|
||||
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
|
||||
const dlMs = new Date(a.deadline) - Date.now();
|
||||
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||||
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
|
||||
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
|
||||
const days = Math.ceil(dlMs / 86400000);
|
||||
const txt = days === 1 ? '1 день' : `${days} дн.`;
|
||||
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
|
||||
}
|
||||
|
||||
function actionFor(a) {
|
||||
const t = asgnType(a);
|
||||
if (t === 'textbook') {
|
||||
let hash = '';
|
||||
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
|
||||
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
|
||||
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
|
||||
}
|
||||
if (t === 'file') {
|
||||
const sub = _subByAsgn.get(a.id);
|
||||
const submit = sub && sub.status !== 'revision'
|
||||
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
|
||||
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
|
||||
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
|
||||
}
|
||||
if (t === 'upload') {
|
||||
const sub = _subByAsgn.get(a.id);
|
||||
if (sub && sub.status !== 'revision') {
|
||||
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
|
||||
}
|
||||
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
|
||||
}
|
||||
// test
|
||||
const inProgress = a.session_status === 'in_progress';
|
||||
const isDone = a.session_status === 'completed';
|
||||
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
|
||||
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
|
||||
}
|
||||
|
||||
function activeCardHtml(a) {
|
||||
const t = asgnType(a);
|
||||
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
|
||||
const icon = t === 'textbook' ? 'book-open-text'
|
||||
: t === 'file' ? 'paperclip'
|
||||
: t === 'upload' ? 'upload'
|
||||
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
const over = dlMs < 0;
|
||||
const urgent = !over && dlMs < 24 * 3600 * 1000;
|
||||
const cls = over ? ' over' : urgent ? ' urgent' : '';
|
||||
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
|
||||
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
|
||||
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
|
||||
return `<div class="hw-acard${cls}" style="--ac:${color}">
|
||||
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
|
||||
<div class="hw-acard-body">
|
||||
<div class="hw-acard-title">${esc(a.title)}</div>
|
||||
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
|
||||
</div>
|
||||
<div class="hw-acard-right">${actionFor(a)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderActiveAssignments() {
|
||||
const wrap = document.getElementById('hw-active-wrap');
|
||||
const list = document.getElementById('hw-active-list');
|
||||
if (!wrap || !list) return;
|
||||
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
|
||||
// обычные тесты/экзамены сюда не попадают.
|
||||
const active = _assignments
|
||||
.filter(a => a.is_homework && !asgnDone(a))
|
||||
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
|
||||
if (!active.length) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = '';
|
||||
document.getElementById('hw-active-count').textContent = active.length;
|
||||
list.innerHTML = active.map(activeCardHtml).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
|
||||
function populateAssignmentSelect(list) {
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
|
||||
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
|
||||
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
|
||||
opt.dataset.asgn = '1';
|
||||
if (a.class_id) opt.dataset.class = a.class_id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Начать/продолжить тест-задание (как на дашборде).
|
||||
async function startAsgn(e, id, mode) {
|
||||
const btn = e.currentTarget;
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await LS.startAssignment(id);
|
||||
if (r.error && r.max_attempts) {
|
||||
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
|
||||
btn.disabled = false; btn.textContent = orig; return;
|
||||
}
|
||||
const aMode = r.assignment_mode || mode || 'exam';
|
||||
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
|
||||
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
|
||||
} catch (err) {
|
||||
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
|
||||
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
|
||||
btn.disabled = false; btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
|
||||
function sdatNow(assignId) {
|
||||
const wrap = document.getElementById('hw-upload-wrap');
|
||||
if (!wrap || wrap.style.display === 'none') {
|
||||
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
|
||||
}
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const area = document.getElementById('hw-upload-area');
|
||||
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
|
||||
}
|
||||
|
||||
/* ── TEACHER VIEW ── */
|
||||
async function initTeacher() {
|
||||
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
|
||||
|
||||
@@ -284,9 +284,15 @@
|
||||
el.innerHTML = rows.map(r => {
|
||||
const dt = new Date(r.created_at);
|
||||
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
||||
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
|
||||
const isClient = r.level === 'client';
|
||||
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
|
||||
const badge = isClient
|
||||
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
|
||||
: '';
|
||||
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||||
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
|
||||
${badge}
|
||||
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
|
||||
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
}
|
||||
|
||||
async function bulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
|
||||
if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
const classes = _targets.classes || [];
|
||||
try {
|
||||
await Promise.all(classes.map(c =>
|
||||
@@ -436,7 +436,7 @@
|
||||
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
|
||||
const srcId = Number(sel.value);
|
||||
const srcName = sel.options[sel.selectedIndex].text;
|
||||
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
|
||||
if (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) return;
|
||||
try {
|
||||
const src = await LS.accessClassOpen(srcId);
|
||||
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
|
||||
@@ -449,7 +449,7 @@
|
||||
}
|
||||
|
||||
async function classBulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||||
if (!allow && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||||
try {
|
||||
await Promise.all(all.map(([type, ref]) =>
|
||||
@@ -543,7 +543,7 @@
|
||||
const classes = _matrix.classes || [];
|
||||
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
|
||||
const open = !allOpen;
|
||||
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
|
||||
if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
try {
|
||||
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
|
||||
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
|
||||
@@ -557,7 +557,7 @@
|
||||
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
|
||||
const open = !allOpen;
|
||||
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
|
||||
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
|
||||
if (!open && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
try {
|
||||
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
|
||||
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let inited = false;
|
||||
|
||||
const GAME_FEATURES = [
|
||||
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
|
||||
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
||||
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
||||
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
||||
@@ -13,12 +14,16 @@
|
||||
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
||||
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
|
||||
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
|
||||
{ key: 'sitemap', label: 'Путеводитель', desc: 'Пункт «Путеводитель» в меню — обзорная карта разделов системы', icon: 'map' },
|
||||
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
|
||||
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
|
||||
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
|
||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
||||
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
||||
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
|
||||
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
|
||||
];
|
||||
|
||||
const FS_FEATURES = [
|
||||
|
||||
@@ -452,6 +452,24 @@
|
||||
<i data-lucide="file-text"></i> Audit log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
|
||||
<div class="ov-card danger" style="padding-bottom:16px">
|
||||
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
|
||||
Сброс системы «чистый запуск»
|
||||
</div>
|
||||
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
|
||||
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
|
||||
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются — авторский контент
|
||||
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
|
||||
Действие необратимо.
|
||||
</div>
|
||||
<button class="ov-quick-btn" id="ov-reset-system-btn"
|
||||
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
|
||||
<i data-lucide="trash-2"></i> Сбросить систему…
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/* ── wire quick-links via event delegation ───────────────── */
|
||||
@@ -459,9 +477,119 @@
|
||||
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
|
||||
});
|
||||
|
||||
const resetBtn = el.querySelector('#ov-reset-system-btn');
|
||||
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
|
||||
async function openResetModal() {
|
||||
const e = LS.esc;
|
||||
const m = LS.modal({
|
||||
title: 'Сброс системы — чистый запуск',
|
||||
size: 'md',
|
||||
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
|
||||
actions: [{ label: 'Отмена' }],
|
||||
});
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await LS.api('/api/admin/reset-system/plan');
|
||||
} catch (err) {
|
||||
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const kept = plan.keptAdmin || {};
|
||||
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
|
||||
const wipeRows = plan.wipeRows || 0;
|
||||
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
|
||||
return a + (typeof r.rows === 'number' ? r.rows : 0);
|
||||
}, 0);
|
||||
const unknownNote = (plan.unknown && plan.unknown.length)
|
||||
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
|
||||
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
|
||||
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
|
||||
: '';
|
||||
|
||||
m.setBody(
|
||||
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
|
||||
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
|
||||
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
|
||||
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
|
||||
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
|
||||
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
|
||||
' <span style="color:#56687A">(вы)</span></div>' +
|
||||
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
|
||||
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
|
||||
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
|
||||
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
|
||||
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
|
||||
'</ul>' +
|
||||
unknownNote +
|
||||
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
|
||||
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
|
||||
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
|
||||
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
const inp = m.body.querySelector('#ov-reset-confirm-inp');
|
||||
function syncBtn() {
|
||||
const ok = inp && inp.value.trim() === 'СБРОС';
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (btn) btn.disabled = !ok;
|
||||
}
|
||||
|
||||
function setReadyActions() {
|
||||
m.setActions([
|
||||
{ label: 'Отмена' },
|
||||
{
|
||||
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
|
||||
onClick: doReset,
|
||||
},
|
||||
]);
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (btn) btn.disabled = true;
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (!inp || inp.value.trim() !== 'СБРОС') return;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
|
||||
m.setError('');
|
||||
let res;
|
||||
try {
|
||||
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
|
||||
} catch (err) {
|
||||
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
|
||||
return;
|
||||
}
|
||||
m.setBody(
|
||||
'<div style="text-align:center;padding:14px 0">' +
|
||||
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
|
||||
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
|
||||
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
|
||||
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
|
||||
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
|
||||
(res.remainingUsers || 1) + '</strong>.<br>' +
|
||||
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
|
||||
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
|
||||
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
|
||||
}
|
||||
|
||||
setReadyActions();
|
||||
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById('overview-content');
|
||||
if (!el) return;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
|
||||
// Старт сессии поддерживает только 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' };
|
||||
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
<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)">${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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<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-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||
</div>
|
||||
@@ -77,16 +79,15 @@
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const [t, subjectQs] = await Promise.all([
|
||||
LS.getTest(id),
|
||||
LS.getQuestions(
|
||||
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
|
||||
null, 'date_asc'
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
const t = await LS.getTest(id);
|
||||
const inIds = new Set(t.questions.map(q => q.id));
|
||||
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
|
||||
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
|
||||
const prev = _tstPickerCache[id] || {};
|
||||
_tstPickerCache[id] = {
|
||||
subject_slug: t.subject_slug, inIds,
|
||||
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
|
||||
rows: [], total: 0, page: 1, loading: false,
|
||||
};
|
||||
|
||||
inner.innerHTML = `
|
||||
<div class="tst-cols">
|
||||
@@ -96,17 +97,89 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="tst-panel-title">Добавить вопросы</div>
|
||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
|
||||
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
|
||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
|
||||
<div class="tst-pick-filters">
|
||||
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
|
||||
<option value="">Любая сложность</option>
|
||||
<option value="1">Лёгкий</option>
|
||||
<option value="2">Средний</option>
|
||||
<option value="3">Сложный</option>
|
||||
</select>
|
||||
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
|
||||
<option value="">Любой тип</option>
|
||||
<option value="single">Один</option>
|
||||
<option value="multi">Несколько</option>
|
||||
<option value="true_false">Верно/Нет</option>
|
||||
<option value="short_answer">Краткий</option>
|
||||
<option value="matching">Сопоставление</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
|
||||
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
// restore search/filters into controls
|
||||
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
|
||||
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
|
||||
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
|
||||
AdminCtx.renderMath(inner);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
await pickerLoad(id, true);
|
||||
} catch (e) {
|
||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
|
||||
reset=true — новый поиск/фильтр (страница 1, заменяем); иначе — «показать ещё». */
|
||||
async function pickerLoad(id, reset) {
|
||||
const cache = _tstPickerCache[id];
|
||||
if (!cache || cache.loading) return;
|
||||
cache.loading = true;
|
||||
if (reset) { cache.page = 1; cache.rows = []; }
|
||||
const listEl = document.getElementById('tstpicker-' + id);
|
||||
const footEl = document.getElementById('tstfoot-' + id);
|
||||
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
p.set('subject', cache.subject_slug || '');
|
||||
p.set('sort', 'date_desc');
|
||||
p.set('page', cache.page);
|
||||
p.set('limit', 100);
|
||||
if (cache.q) p.set('q', cache.q);
|
||||
if (cache.difficulty) p.set('difficulty', cache.difficulty);
|
||||
if (cache.type) p.set('type', cache.type);
|
||||
const data = await LS.get('/api/questions?' + p.toString());
|
||||
const rows = Array.isArray(data) ? data : (data.rows || []);
|
||||
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
|
||||
cache.rows = reset ? rows : cache.rows.concat(rows);
|
||||
cache.page += 1;
|
||||
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
|
||||
if (footEl) footEl.innerHTML = pickerFootHtml(id);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (e) {
|
||||
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
|
||||
} finally { cache.loading = false; }
|
||||
}
|
||||
|
||||
function pickerFootHtml(id) {
|
||||
const c = _tstPickerCache[id];
|
||||
if (!c || !c.total) return '';
|
||||
const more = c.rows.length < c.total
|
||||
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
|
||||
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
|
||||
}
|
||||
|
||||
function pickerMore(id) { pickerLoad(id, false); }
|
||||
|
||||
function pickerFilterChange(id) {
|
||||
const c = _tstPickerCache[id];
|
||||
if (!c) return;
|
||||
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
|
||||
c.type = document.getElementById('tstft-' + id)?.value || '';
|
||||
pickerLoad(id, true);
|
||||
}
|
||||
|
||||
function renderTstQList(questions, tid) {
|
||||
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>';
|
||||
@@ -127,7 +200,11 @@
|
||||
|
||||
function renderTstPicker(questions, inIds, tid) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||||
if (!questions.length) {
|
||||
const c = _tstPickerCache[tid] || {};
|
||||
const searching = c.q || c.difficulty || c.type;
|
||||
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</div>`;
|
||||
}
|
||||
return questions.map(q => {
|
||||
const added = inIds.has(q.id);
|
||||
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
||||
@@ -145,15 +222,13 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function filterTstPicker(tid) {
|
||||
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
|
||||
const cache = _tstPickerCache[tid];
|
||||
const _pickDebounce = {};
|
||||
function filterTstPicker(tid) {
|
||||
const cache = _tstPickerCache[tid];
|
||||
if (!cache) return;
|
||||
const filtered = search
|
||||
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
|
||||
: cache.subjectQs;
|
||||
const picker = document.getElementById('tstpicker-'+tid);
|
||||
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
|
||||
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
|
||||
clearTimeout(_pickDebounce[tid]);
|
||||
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
|
||||
}
|
||||
|
||||
async function tstAddQ(tid, qid) {
|
||||
@@ -261,11 +336,27 @@
|
||||
} 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
|
||||
window.loadTests = load;
|
||||
window.renderTests = renderTests;
|
||||
window.toggleTstDrawer = toggleTstDrawer;
|
||||
window.filterTstPicker = filterTstPicker;
|
||||
window.pickerMore = pickerMore;
|
||||
window.pickerFilterChange = pickerFilterChange;
|
||||
window.tstAddQ = tstAddQ;
|
||||
window.tstRemoveQ = tstRemoveQ;
|
||||
window.setTstShowAnswers = setTstShowAnswers;
|
||||
@@ -274,6 +365,7 @@
|
||||
window.closeTstModal = closeTstModal;
|
||||
window.saveTst = saveTst;
|
||||
window.deleteTst = deleteTst;
|
||||
window.toggleTstAvail = toggleTstAvail;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.tests = {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
/* assignment-utils.js — единый источник правды для классификации заданий и статуса «сдано».
|
||||
*
|
||||
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
|
||||
* assignmentController.js) и начала расходиться. Теперь — один модуль, который грузится
|
||||
* и в браузере (window.AssignmentUtils), и в Node (module.exports) — как svg-sanitize.js.
|
||||
*
|
||||
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
|
||||
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
|
||||
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
|
||||
*/
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (typeof window !== 'undefined') window.AssignmentUtils = api;
|
||||
})(this, function () {
|
||||
|
||||
/* Тип задания: textbook | file | upload | test.
|
||||
Порядок проверки: учебник → файл → загрузка работы → тест. */
|
||||
function type(a) {
|
||||
if (a.textbook_id) return 'textbook';
|
||||
if (a.file_id) return 'file';
|
||||
if (a.is_homework && (a.count == null || a.count <= 1)
|
||||
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
/* «Закрыто» (сдано/выполнено/прочитано) — задание уходит из активных/долгов.
|
||||
sub — последняя сдача (объект с .status) для upload/file, иначе null/undefined.
|
||||
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
|
||||
status==='accepted' (пока не приняли — у ученика «висит»).
|
||||
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
|
||||
(ученик свою часть сделал — это уже не его долг). */
|
||||
function isDone(a, sub, opts) {
|
||||
opts = opts || {};
|
||||
const t = type(a);
|
||||
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
|
||||
if (t === 'upload' || t === 'file') {
|
||||
const st = sub && sub.status;
|
||||
if (!st || st === 'revision') return false;
|
||||
return opts.acceptedOnly ? st === 'accepted' : true;
|
||||
}
|
||||
// test
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const used = (a.attempts_used != null) ? a.attempts_used : 0;
|
||||
if (maxAtt > 0 && used >= maxAtt) return true;
|
||||
return a.session_status === 'completed' && a.mode !== 'repeat';
|
||||
}
|
||||
|
||||
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4;
|
||||
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
|
||||
if (dlMs < 0) return -3;
|
||||
if (dlMs < 24 * 3600 * 1000) return -2;
|
||||
if (dlMs < Infinity) return dlMs;
|
||||
return 1e12;
|
||||
}
|
||||
|
||||
return { type, isDone, urgencyScore };
|
||||
});
|
||||
@@ -197,7 +197,10 @@
|
||||
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
|
||||
title: s.user_name || '—',
|
||||
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
|
||||
act: 'Открыть', actHash: '/admin#sessions', solid: true,
|
||||
// Глубокая ссылка на ДЕТАЛИ конкретной сессии (открывается при любом статусе):
|
||||
// список /admin#sessions показывает только completed, поэтому зависшая (in_progress)
|
||||
// там не находилась. На странице деталей её можно посмотреть и удалить.
|
||||
act: 'Открыть', actHash: '/admin#sessions/' + s.id, solid: true,
|
||||
});
|
||||
});
|
||||
const ab = d.abandonedSessions24h || 0;
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
|
||||
/* Человекочитаемая метка варианта (ЦТ-2015 и т.п.); фолбэк — «Вариант N». */
|
||||
const labelOf = (n) => {
|
||||
const v = variants.find(x => x.n === n);
|
||||
return (v && v.label) || `Вариант ${n}`;
|
||||
};
|
||||
|
||||
/* ── Picker overlay ─────────────────────────────────────────── */
|
||||
function buildGrid() {
|
||||
pickerGrid.innerHTML = variants.map(v => {
|
||||
@@ -45,7 +51,7 @@
|
||||
const active = v.n === currentN ? ' active' : '';
|
||||
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.label || v.n}</button>`;
|
||||
}).join('');
|
||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
|
||||
@@ -74,7 +80,7 @@
|
||||
/* ── Variant rendering ──────────────────────────────────────── */
|
||||
async function selectVariant(n) {
|
||||
currentN = n;
|
||||
pickerLabel.textContent = `Вариант ${n}`;
|
||||
pickerLabel.textContent = labelOf(n);
|
||||
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
|
||||
|
||||
if (!tasksCache.has(n)) {
|
||||
@@ -94,7 +100,7 @@
|
||||
}
|
||||
|
||||
function renderVariant(n, tasks) {
|
||||
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
|
||||
main.innerHTML = `<div class="vp-title">${labelOf(n)}<small>${tasks.length} заданий</small></div>`;
|
||||
|
||||
const variantMeta = variants.find(v => v.n === n);
|
||||
const solvedTracked = new Set(); // tasks already solved this session
|
||||
|
||||
+369
-33
@@ -53,6 +53,8 @@ class TrigCircleSim {
|
||||
this.graphFn = 'sin';
|
||||
this.snapToNotable = true;
|
||||
this.animating = false;
|
||||
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
|
||||
this.showParity = false; // показать зеркальную точку −α (чётность/нечётность)
|
||||
|
||||
this._cx = 0; this._cy = 0; this._r = 0;
|
||||
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
|
||||
@@ -96,11 +98,14 @@ class TrigCircleSim {
|
||||
|
||||
this._drawBg(c);
|
||||
this._drawCircle(c);
|
||||
if (this.eq) this._drawEquation(c);
|
||||
if (this.showParity) this._drawParity(c);
|
||||
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
|
||||
this._drawParticles(c);
|
||||
if (window.LabFX) LabFX.particles.draw(c);
|
||||
|
||||
c.restore();
|
||||
this._ovClearUnused();
|
||||
this._fireUpdate();
|
||||
}
|
||||
|
||||
@@ -116,6 +121,102 @@ class TrigCircleSim {
|
||||
this._layout(); this.draw();
|
||||
}
|
||||
|
||||
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
|
||||
setEquation(fn, a, sols) {
|
||||
this.eq = { fn, a, sols: sols || [] };
|
||||
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
|
||||
this.draw();
|
||||
}
|
||||
clearEquation() { this.eq = null; this.draw(); }
|
||||
|
||||
_drawEquation(c) {
|
||||
const cx = this._cx, cy = this._cy, r = this._r;
|
||||
const { fn, a, sols } = this.eq;
|
||||
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
|
||||
c.save();
|
||||
/* направляющая линия значения */
|
||||
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
|
||||
c.beginPath();
|
||||
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
|
||||
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
|
||||
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
|
||||
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
|
||||
c.stroke(); c.setLineDash([]);
|
||||
/* точки-решения + подписи градусов */
|
||||
c.font = 'bold 11px Manrope,sans-serif';
|
||||
sols.forEach(ang => {
|
||||
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
|
||||
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
|
||||
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
|
||||
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
|
||||
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
|
||||
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
|
||||
});
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
|
||||
_drawParity(c) {
|
||||
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
|
||||
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
|
||||
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
|
||||
c.save();
|
||||
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
|
||||
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
|
||||
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
|
||||
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
|
||||
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
||||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
|
||||
_ov() {
|
||||
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
|
||||
return this._ovEl;
|
||||
}
|
||||
/* key — стабильный id подписи; latex — LaTeX (дробь/корень → KaTeX, иначе текст);
|
||||
x,y — CSS-px над canvas; anchor: c|l|r|t|b; boxed — тёмная плашка (для координат). */
|
||||
_ovLabel(key, latex, x, y, color, anchor, boxed) {
|
||||
const ov = this._ov(); if (!ov) return;
|
||||
this._ovMap = this._ovMap || {};
|
||||
this._ovUsed = this._ovUsed || {};
|
||||
let rec = this._ovMap[key];
|
||||
if (!rec) {
|
||||
const el = document.createElement('div');
|
||||
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
|
||||
el.style.willChange = 'transform';
|
||||
ov.appendChild(el);
|
||||
rec = this._ovMap[key] = { el, last: null, boxed: null };
|
||||
}
|
||||
if (rec.last !== latex) {
|
||||
const useK = /\\tfrac|\\sqrt|\\left|\\frac/.test(latex) && (typeof window !== 'undefined' && window.katex);
|
||||
if (useK) rec.el.innerHTML = window.katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false });
|
||||
else rec.el.textContent = latex;
|
||||
rec.last = latex;
|
||||
}
|
||||
if (rec.boxed !== !!boxed) {
|
||||
rec.el.style.cssText += boxed
|
||||
? ';background:rgba(12,12,22,0.82);border:1px solid rgba(155,93,229,0.3);border-radius:8px;padding:3px 9px'
|
||||
: ';background:none;border:none;padding:0';
|
||||
rec.boxed = !!boxed;
|
||||
}
|
||||
rec.el.style.color = color || '#fff';
|
||||
const a = anchor || 'c';
|
||||
const tr = a === 'r' ? 'translate(-100%,-50%)' : a === 'l' ? 'translate(0,-50%)'
|
||||
: a === 't' ? 'translate(-50%,0)' : a === 'b' ? 'translate(-50%,-100%)' : 'translate(-50%,-50%)';
|
||||
rec.el.style.transform = `translate(${x}px,${y}px) ${tr}`;
|
||||
rec.el.style.display = '';
|
||||
this._ovUsed[key] = true;
|
||||
}
|
||||
_ovClearUnused() {
|
||||
if (!this._ovMap) return;
|
||||
for (const k in this._ovMap) if (!(this._ovUsed && this._ovUsed[k])) this._ovMap[k].el.style.display = 'none';
|
||||
this._ovUsed = {};
|
||||
}
|
||||
|
||||
goToAngle(rad) {
|
||||
this._animTarget = this._norm(rad);
|
||||
if (!this.animating) this._startAnim();
|
||||
@@ -130,7 +231,16 @@ class TrigCircleSim {
|
||||
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
|
||||
const deg = a * 180 / Math.PI;
|
||||
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
|
||||
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
|
||||
// Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
|
||||
let ref;
|
||||
if (a <= Math.PI / 2) ref = a;
|
||||
else if (a <= Math.PI) ref = Math.PI - a;
|
||||
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
|
||||
else ref = 2 * Math.PI - a;
|
||||
return {
|
||||
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
|
||||
refAngle: ref, refDeg: ref * 180 / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
/* ═══ Layout ═══════════════════════════════════════════════════════ */
|
||||
@@ -290,11 +400,10 @@ class TrigCircleSim {
|
||||
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
|
||||
c.strokeStyle = ag; c.lineWidth = 2.5;
|
||||
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
|
||||
/* label */
|
||||
const mid = a / 2, lr = ar + 18;
|
||||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
||||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
|
||||
/* label (KaTeX overlay: π-доля для табличных, иначе текст) */
|
||||
const mid = a / 2, lr = ar + 20;
|
||||
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
|
||||
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
|
||||
}
|
||||
|
||||
/* ── radius ── */
|
||||
@@ -388,9 +497,9 @@ class TrigCircleSim {
|
||||
|
||||
/* ── axis value badges ── */
|
||||
if (this.showSin && Math.abs(sinA) > 0.04)
|
||||
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
|
||||
this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
|
||||
if (this.showCos && Math.abs(cosA) > 0.04)
|
||||
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
|
||||
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
|
||||
|
||||
/* ── main point ── */
|
||||
const ps = this._hover || this._drag ? 10 : 8;
|
||||
@@ -406,8 +515,12 @@ class TrigCircleSim {
|
||||
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
|
||||
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
|
||||
|
||||
/* ── coordinate tooltip ── */
|
||||
this._tooltip(c, px, py, cosA, sinA);
|
||||
/* ── coordinate tooltip (KaTeX overlay) — выносим РАДИАЛЬНО НАРУЖУ за точку,
|
||||
чтобы не перекрывать центральную дугу угла и её подпись ── */
|
||||
const _odx = Math.cos(a), _ody = -Math.sin(a);
|
||||
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
|
||||
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
|
||||
'#fff', cosA >= 0 ? 'l' : 'r', true);
|
||||
|
||||
/* ── quadrant roman numeral ── */
|
||||
const qOff = r * 0.46;
|
||||
@@ -687,17 +800,8 @@ class TrigCircleSim {
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
|
||||
/* value badge */
|
||||
const txt = this._fmt(curY);
|
||||
c.font = 'bold 11px Manrope,sans-serif';
|
||||
const tm = c.measureText(txt);
|
||||
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
|
||||
c.fillStyle='rgba(12,12,22,0.85)';
|
||||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
|
||||
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
|
||||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
|
||||
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
|
||||
c.fillText(txt, bx2+7, by2);
|
||||
/* value badge (KaTeX overlay) */
|
||||
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
|
||||
}
|
||||
|
||||
c.restore();
|
||||
@@ -1029,6 +1133,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
}
|
||||
|
||||
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
|
||||
goToAngle нормализует — заодно демонстрирует котерминальность. */
|
||||
function trigSetAngleDeg(inp) {
|
||||
if (!trigSim || !inp) return;
|
||||
const v = parseFloat(String(inp.value || '').replace(',', '.'));
|
||||
if (!isFinite(v)) return;
|
||||
trigSim.goToAngle(v * Math.PI / 180);
|
||||
}
|
||||
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
|
||||
|
||||
/* Показать/скрыть график функций (тема «функции» — по умолчанию можно убрать,
|
||||
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
|
||||
function trigToggleGraph(rowEl) {
|
||||
if (!trigSim) return;
|
||||
const on = rowEl.classList.toggle('active');
|
||||
trigSim.toggleLayer('graph', on);
|
||||
const fns = document.getElementById('trig-graph-fns');
|
||||
if (fns) fns.style.display = on ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
|
||||
function _trigSolveAngles(fn, a) {
|
||||
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
|
||||
let raw;
|
||||
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
|
||||
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
|
||||
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
|
||||
const out = [];
|
||||
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
|
||||
return out.sort((p, q) => p - q);
|
||||
}
|
||||
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
|
||||
function _radLatex(rad) {
|
||||
const P = Math.PI;
|
||||
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
|
||||
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
|
||||
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
|
||||
for (const [v, l] of T) {
|
||||
if (Math.abs(rad - v) < 1e-6) return l;
|
||||
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* Общая формула решения (LaTeX) или {none:true}. */
|
||||
function _trigEqFormulaLatex(fn, a) {
|
||||
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
|
||||
if (fn === 'sin') {
|
||||
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
|
||||
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
if (fn === 'cos') {
|
||||
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
|
||||
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
|
||||
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
|
||||
var trigEqFn = 'sin';
|
||||
function trigSetEqFn(fn, btn) {
|
||||
trigEqFn = fn;
|
||||
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
function trigSolve() {
|
||||
if (!trigSim) return;
|
||||
const inp = document.getElementById('trig-eq-input');
|
||||
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
|
||||
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
|
||||
const fEl = document.getElementById('trig-eq-formula');
|
||||
const sEl = document.getElementById('trig-eq-sols');
|
||||
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
|
||||
const sols = _trigSolveAngles(trigEqFn, a);
|
||||
trigSim.setEquation(trigEqFn, a, sols);
|
||||
const K = window.katex;
|
||||
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
|
||||
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
|
||||
const f = _trigEqFormulaLatex(trigEqFn, a);
|
||||
if (fEl) {
|
||||
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
|
||||
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
|
||||
}
|
||||
if (sEl) sEl.textContent = sols.length
|
||||
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
|
||||
: '';
|
||||
}
|
||||
function trigClearEq() {
|
||||
if (!trigSim) return;
|
||||
trigSim.clearEquation();
|
||||
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
|
||||
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
|
||||
}
|
||||
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
|
||||
|
||||
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
|
||||
function _trigBuildValueTable() {
|
||||
const el = document.getElementById('trig-table');
|
||||
if (!el || el.dataset.built) return;
|
||||
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
|
||||
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
|
||||
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
|
||||
const body = [0, 30, 45, 60, 90].map(deg => {
|
||||
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
|
||||
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
|
||||
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
|
||||
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
|
||||
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
|
||||
}).join('');
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
|
||||
el.dataset.built = '1';
|
||||
}
|
||||
function trigToggleTable(rowEl) {
|
||||
const on = rowEl.classList.toggle('active');
|
||||
const el = document.getElementById('trig-table');
|
||||
if (!el) return;
|
||||
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
|
||||
else el.style.display = 'none';
|
||||
}
|
||||
|
||||
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
|
||||
function trigToggleParity(rowEl) {
|
||||
if (!trigSim) return;
|
||||
const on = rowEl.classList.toggle('active');
|
||||
trigSim.showParity = on;
|
||||
trigSim.draw();
|
||||
const pEl = document.getElementById('trig-parity');
|
||||
if (!pEl) return;
|
||||
pEl.style.display = on ? '' : 'none';
|
||||
if (on && !pEl.dataset.built) {
|
||||
pEl.innerHTML =
|
||||
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
|
||||
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
|
||||
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
|
||||
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
|
||||
pEl.dataset.built = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function _trigUpdateUI(s) {
|
||||
const _f = v => {
|
||||
if (v === undefined) return '—';
|
||||
@@ -1044,25 +1286,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
||||
};
|
||||
const degStr = s.deg.toFixed(1) + '°';
|
||||
|
||||
// Panel values (nice fractions)
|
||||
document.getElementById('trig-v-sin').textContent = _f(s.sin);
|
||||
document.getElementById('trig-v-cos').textContent = _f(s.cos);
|
||||
document.getElementById('trig-v-tan').textContent = _f(s.tan);
|
||||
document.getElementById('trig-v-cot').textContent = _f(s.cot);
|
||||
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
|
||||
const setMathVal = (id, v) => {
|
||||
const el = document.getElementById(id); if (!el) return;
|
||||
const lx = _latexVal(v);
|
||||
if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
|
||||
else el.textContent = lx;
|
||||
};
|
||||
setMathVal('trig-v-sin', s.sin);
|
||||
setMathVal('trig-v-cos', s.cos);
|
||||
setMathVal('trig-v-tan', s.tan);
|
||||
setMathVal('trig-v-cot', s.cot);
|
||||
|
||||
// Angle badge
|
||||
// Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
|
||||
const al = _angleLatex(s.angle);
|
||||
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
|
||||
document.getElementById('trig-angle-badge').innerHTML =
|
||||
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`;
|
||||
`<div>${_tex(head)}</div>` +
|
||||
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
|
||||
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
|
||||
|
||||
// Stats bar (nice fractions)
|
||||
// Опорный (острый) угол — guarded (панель может не иметь элемента)
|
||||
const refEl = document.getElementById('trig-ref');
|
||||
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
|
||||
// Знаки функций в текущей четверти
|
||||
const signsEl = document.getElementById('trig-signs');
|
||||
if (signsEl) {
|
||||
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '−' : '0');
|
||||
signsEl.innerHTML =
|
||||
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
|
||||
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
|
||||
}
|
||||
|
||||
// Точные значения + формула приведения (только для табличных углов)
|
||||
const fEl = document.getElementById('trig-formula');
|
||||
if (fEl) {
|
||||
const beta = Math.round(s.refDeg);
|
||||
const degR = Math.round(s.deg);
|
||||
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
|
||||
if (!isTable) {
|
||||
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
|
||||
} else {
|
||||
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
|
||||
const K = window.katex;
|
||||
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
|
||||
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
|
||||
let html = '';
|
||||
if (reduce) {
|
||||
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
|
||||
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
|
||||
: `360^\\circ - ${beta}^\\circ`;
|
||||
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
|
||||
}
|
||||
const line = (nm, color, val) => {
|
||||
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
|
||||
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
|
||||
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
|
||||
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
|
||||
};
|
||||
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
|
||||
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
|
||||
}
|
||||
}
|
||||
|
||||
// Подсветка строки таблицы значений (по опорному острому углу)
|
||||
const tbl = document.getElementById('trig-table');
|
||||
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
|
||||
const beta = Math.round(s.refDeg);
|
||||
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
|
||||
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Stats bar — значения тоже KaTeX (дроби/корни)
|
||||
document.getElementById('trigbar-angle').textContent = degStr;
|
||||
document.getElementById('trigbar-sin').textContent = _f(s.sin);
|
||||
document.getElementById('trigbar-cos').textContent = _f(s.cos);
|
||||
document.getElementById('trigbar-tan').textContent = _f(s.tan);
|
||||
document.getElementById('trigbar-cot').textContent = _f(s.cot);
|
||||
setMathVal('trigbar-sin', s.sin);
|
||||
setMathVal('trigbar-cos', s.cos);
|
||||
setMathVal('trigbar-tan', s.tan);
|
||||
setMathVal('trigbar-cot', s.cot);
|
||||
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
|
||||
}
|
||||
|
||||
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
|
||||
function _latexVal(v) {
|
||||
if (v === undefined) return '\\text{не опр.}';
|
||||
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
|
||||
if (a < 5e-4) return '0';
|
||||
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
|
||||
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
|
||||
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
|
||||
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
|
||||
if (Math.abs(a - 1) < 1e-3) return sg + '1';
|
||||
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
|
||||
return v.toFixed(3);
|
||||
}
|
||||
|
||||
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
|
||||
function _tex(latex) {
|
||||
const K = window.katex;
|
||||
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
|
||||
}
|
||||
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
|
||||
function _piLabelToLatex(l) {
|
||||
if (l === '0') return '0';
|
||||
const conv = s => s.replace('π', '\\pi');
|
||||
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
|
||||
return conv(l);
|
||||
}
|
||||
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
|
||||
function _angleLatex(rad) {
|
||||
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── KaTeX live preview ── */
|
||||
|
||||
/** Convert user ascii expression <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> LaTeX string for KaTeX preview */
|
||||
|
||||
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
|
||||
LS.notif.init();
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures();
|
||||
if (feats.knowledge_map === false) { window.location.replace('/403'); return; }
|
||||
if (feats.knowledge_map === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
|
||||
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
|
||||
|
||||
@@ -506,6 +506,16 @@
|
||||
<!-- left panel -->
|
||||
<div class="proj-panel" style="width:240px;gap:0">
|
||||
|
||||
<!-- Angle input -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Угол, °</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px">
|
||||
<input id="trig-angle-input" type="number" step="1" placeholder="напр. 150"
|
||||
onkeydown="trigAngleKey(event,this)"
|
||||
style="flex:1;min-width:0;padding:7px 10px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
|
||||
<button class="preset-btn" style="flex-shrink:0;padding:7px 12px" title="Перейти к углу"
|
||||
onclick="trigSetAngleDeg(document.getElementById('trig-angle-input'))"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- Function toggles -->
|
||||
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
||||
@@ -535,9 +545,14 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Graph function selector -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">График</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
|
||||
<!-- Graph (functions) — optional, can be hidden to focus on the circle -->
|
||||
<label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">График</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">функции</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-graph-fns" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
|
||||
<button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button>
|
||||
<button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button>
|
||||
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
|
||||
@@ -553,6 +568,55 @@
|
||||
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Reference (acute) angle + signs by quadrant -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Опорный угол · знаки</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.78rem">
|
||||
<span style="color:var(--text-3)">острый угол к оси</span>
|
||||
<span id="trig-ref" style="font-weight:800;color:var(--violet)">—</span>
|
||||
</div>
|
||||
<div id="trig-signs" style="text-align:center;font-size:0.72rem;color:var(--text-2)">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Exact values + reduction formula (table angles) -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
|
||||
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px">—</div>
|
||||
|
||||
<!-- Equation solver: fn(x) = a -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
|
||||
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
|
||||
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
|
||||
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
|
||||
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
|
||||
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
|
||||
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
|
||||
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
|
||||
</div>
|
||||
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
|
||||
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
|
||||
|
||||
<!-- Values table (first quadrant), toggle -->
|
||||
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleTable(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">Таблица значений</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">0–90°</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-table" style="display:none;margin-bottom:14px;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:6px 8px;overflow-x:auto"></div>
|
||||
|
||||
<!-- Parity (−α) + periods toggle -->
|
||||
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleParity(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">Чётность (−α)</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">симметрия</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-parity" style="display:none;margin-bottom:14px;font-size:0.82rem;color:var(--text);line-height:1.7;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:8px 11px"></div>
|
||||
|
||||
<!-- Notable angles -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
|
||||
@@ -562,8 +626,16 @@
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/4)">135°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/6)">150°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/6)">210°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/4)">225°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(4*Math.PI/3)">240°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/3)">300°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/4)">315°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(11*Math.PI/6)">330°</button>
|
||||
</div>
|
||||
|
||||
<!-- Angle info -->
|
||||
@@ -583,8 +655,10 @@
|
||||
</div><!-- /.proj-panel -->
|
||||
|
||||
<!-- canvas -->
|
||||
<div class="proj-canvas-outer">
|
||||
<div class="proj-canvas-outer" style="position:relative">
|
||||
<canvas id="trigcircle-canvas"></canvas>
|
||||
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
|
||||
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sim-body-wrap -->
|
||||
|
||||
@@ -792,7 +792,7 @@ const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
|
||||
async function init() {
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures().catch(() => ({}));
|
||||
if (feats.red_book === false) { window.location.replace('/403'); return; }
|
||||
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
|
||||
// Auth (sidebar)
|
||||
|
||||
@@ -196,7 +196,8 @@
|
||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||
|
||||
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
||||
if (LS.loadFeatures) {
|
||||
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
|
||||
if (LS.loadFeatures && !ip.isAdmin) {
|
||||
LS.loadFeatures().then(function (feats) {
|
||||
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
|
||||
}).catch(function () {});
|
||||
|
||||
@@ -479,6 +479,15 @@
|
||||
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
|
||||
function openLabSim(simId, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
|
||||
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
|
||||
try {
|
||||
const u = LS.getUser && LS.getUser();
|
||||
if (!(u && u.role === 'admin')) {
|
||||
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
|
||||
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
|
||||
}
|
||||
} catch (e) { /* нет кэша — открываем как раньше */ }
|
||||
location.href = '/lab?sim=' + encodeURIComponent(simId);
|
||||
}
|
||||
window.openLabSim = openLabSim;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Пожелания — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; }
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 26px 32px 100px; }
|
||||
|
||||
/* ── hero ── */
|
||||
.wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
|
||||
.wq-hero-txt { flex: 1; min-width: 200px; }
|
||||
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.18rem; font-weight: 800; color: #0F172A; margin-bottom: 4px; }
|
||||
.page-sub { font-size: 0.82rem; color: var(--text-3); line-height: 1.5; }
|
||||
.wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
|
||||
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
|
||||
.wq-new-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(155,93,229,0.34); }
|
||||
.wq-new-btn.open { background: #fff; color: var(--text-2); border: 1.5px solid rgba(15,23,42,0.12); box-shadow: none; }
|
||||
|
||||
/* ── submit form (collapsible) ── */
|
||||
.wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
|
||||
overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
|
||||
.wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
|
||||
.wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
|
||||
.wq-cat-pick { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.wq-cat-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; cursor: pointer;
|
||||
border: 1.5px solid rgba(15,23,42,0.1); background: #fff; font-size: 0.78rem; font-weight: 600; color: var(--text-2); transition: all .15s; }
|
||||
.wq-cat-opt:hover { border-color: var(--cc); }
|
||||
.wq-cat-opt.sel { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
|
||||
.wq-cat-opt i { width: 14px; height: 14px; }
|
||||
.wq-inp, .wq-area { width: 100%; padding: 11px 13px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #0F172A; outline: none; transition: border-color .15s; }
|
||||
.wq-inp:focus, .wq-area:focus { border-color: var(--violet); }
|
||||
.wq-area { min-height: 74px; resize: vertical; margin-top: 10px; }
|
||||
.wq-form-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 10px; }
|
||||
.wq-counter { font-size: 0.72rem; color: var(--text-3); }
|
||||
|
||||
/* ── stat / status filter pills ── */
|
||||
.wq-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.wq-stat { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 13px; cursor: pointer;
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07); transition: all .15s; }
|
||||
.wq-stat:hover { border-color: var(--sc, #9B5DE5); }
|
||||
.wq-stat.active { border-color: var(--sc, #9B5DE5); background: color-mix(in srgb, var(--sc, #9B5DE5) 9%, #fff); }
|
||||
.wq-stat-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--sc, #9B5DE5); flex-shrink: 0; }
|
||||
.wq-stat-lbl { font-size: 0.78rem; font-weight: 600; color: var(--text-2); }
|
||||
.wq-stat-num { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #0F172A; }
|
||||
|
||||
/* ── sub-bar: category filter + search ── */
|
||||
.wq-subbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.wq-cats { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wq-cchip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
|
||||
border: 1.5px solid rgba(15,23,42,0.1); background: transparent; font-size: 0.73rem; font-weight: 600; color: var(--text-3); transition: all .15s; }
|
||||
.wq-cchip:hover { border-color: var(--cc); color: var(--cc); }
|
||||
.wq-cchip.active { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
|
||||
.wq-cchip i { width: 12px; height: 12px; }
|
||||
.wq-search { margin-left: auto; min-width: 180px; flex: 1; max-width: 280px; padding: 8px 13px; border: 1.5px solid rgba(15,23,42,0.1);
|
||||
border-radius: 11px; font-family: 'Manrope', sans-serif; font-size: 0.82rem; outline: none; transition: border-color .15s; }
|
||||
.wq-search:focus { border-color: var(--violet); }
|
||||
|
||||
/* ── wish cards ── */
|
||||
.w-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
|
||||
display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
|
||||
.w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
|
||||
@keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.w-cat-ic { width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: color-mix(in srgb, var(--cc) 13%, #fff); color: var(--cc); }
|
||||
.w-cat-ic i { width: 19px; height: 19px; }
|
||||
.w-main { flex: 1; min-width: 0; }
|
||||
.w-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 3px; }
|
||||
.w-title { font-size: 0.93rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
|
||||
.w-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
.w-badge i { width: 11px; height: 11px; }
|
||||
.wb-new { background: rgba(6,182,212,0.12); color: #06aab3; }
|
||||
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
|
||||
.wb-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
|
||||
.wb-done { background: rgba(5,150,82,0.13); color: #059652; }
|
||||
.wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
|
||||
.w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
|
||||
.w-author { font-weight: 700; color: var(--violet); }
|
||||
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.55; margin-top: 6px; white-space: pre-wrap; word-break: break-word; }
|
||||
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
|
||||
border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
|
||||
.w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
/* admin manage */
|
||||
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
|
||||
.w-sel { padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; font-family: 'Manrope', sans-serif;
|
||||
font-size: 0.8rem; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
|
||||
.w-sel:focus { border-color: var(--violet); }
|
||||
.w-note-inp { flex: 1; min-width: 200px; padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; outline: none; resize: vertical; min-height: 38px; }
|
||||
.w-note-inp:focus { border-color: var(--violet); }
|
||||
.w-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
|
||||
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; color: var(--text-2); cursor: pointer; transition: all .15s; }
|
||||
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
|
||||
.w-btn-primary:hover { opacity: .9; color: #fff; }
|
||||
.w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.w-btn-icon { padding: 8px; color: var(--text-3); }
|
||||
.w-btn-icon:hover { background: rgba(239,71,111,0.08); color: #EF476F; border-color: rgba(239,71,111,0.25); }
|
||||
|
||||
/* empty / skeleton */
|
||||
.w-empty { text-align: center; padding: 54px 20px; color: var(--text-3); }
|
||||
.w-empty-art { width: 80px; height: 80px; margin: 0 auto 14px; border-radius: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(155,93,229,0.08); color: var(--violet); }
|
||||
.w-empty-art i { width: 38px; height: 38px; }
|
||||
.w-empty-t { font-size: 0.92rem; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
|
||||
.w-empty-s { font-size: 0.8rem; }
|
||||
.w-skel { height: 78px; border-radius: 16px; background: linear-gradient(90deg,#eef0f4 25%,#f6f7f9 50%,#eef0f4 75%); background-size: 200% 100%; animation: wqShim 1.3s infinite; }
|
||||
@keyframes wqShim { to { background-position: -200% 0; } }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 16px 14px 80px; }
|
||||
.wq-new-btn { width: 100%; justify-content: center; }
|
||||
.wq-search { margin-left: 0; max-width: none; }
|
||||
.w-card { padding: 13px; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
<div class="sb-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="wq-hero">
|
||||
<div class="wq-hero-icon"><i data-lucide="lightbulb" style="width:24px;height:24px"></i></div>
|
||||
<div class="wq-hero-txt">
|
||||
<div class="page-title">Пожелания по улучшению</div>
|
||||
<div class="page-sub" id="w-sub">Есть идея, как сделать систему лучше? Расскажите — мы прочитаем и ответим.</div>
|
||||
</div>
|
||||
<button class="wq-new-btn" id="wq-new-btn" onclick="toggleForm()">
|
||||
<span id="wq-new-ic"><i data-lucide="plus" style="width:15px;height:15px"></i></span> <span id="wq-new-lbl">Поделиться идеей</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Submit form -->
|
||||
<div class="wq-form collapsed" id="wq-form">
|
||||
<div class="wq-flabel">Категория</div>
|
||||
<div class="wq-cat-pick" id="wq-cat-pick"></div>
|
||||
<input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
|
||||
<textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
|
||||
<div class="wq-form-foot">
|
||||
<span class="wq-counter" id="wf-counter">0 / 200</span>
|
||||
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
|
||||
<i data-lucide="send" style="width:14px;height:14px"></i> Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wq-stats" id="wq-stats"></div>
|
||||
<div class="wq-subbar" id="wq-subbar" style="display:none">
|
||||
<div class="wq-cats" id="wq-cats"></div>
|
||||
<input class="wq-search" id="wq-search" placeholder="Поиск по пожеланиям…" oninput="onSearch(this.value)" />
|
||||
</div>
|
||||
|
||||
<div class="w-list" id="w-list">
|
||||
<div class="w-skel"></div><div class="w-skel"></div><div class="w-skel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script>
|
||||
const { user, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
LS.showBoardIfAllowed();
|
||||
LS.notif.init();
|
||||
|
||||
const CAT = {
|
||||
feature: { label: 'Новая функция', icon: 'sparkles', color: '#9B5DE5' },
|
||||
ui: { label: 'Интерфейс', icon: 'layout-panel-top', color: '#06B6D4' },
|
||||
content: { label: 'Контент', icon: 'book-open', color: '#2563EB' },
|
||||
bug: { label: 'Баг / ошибка', icon: 'bug', color: '#EF476F' },
|
||||
other: { label: 'Другое', icon: 'message-circle', color: '#64748B' },
|
||||
};
|
||||
const CAT_ORDER = ['feature', 'ui', 'content', 'bug', 'other'];
|
||||
const ST = {
|
||||
new: { label: 'Новое', icon: 'sparkle', color: '#06aab3' },
|
||||
planned: { label: 'Запланировано', icon: 'calendar-clock', color: '#9B5DE5' },
|
||||
in_progress: { label: 'В работе', icon: 'loader', color: '#d97706' },
|
||||
done: { label: 'Готово', icon: 'check-circle-2', color: '#059652' },
|
||||
declined: { label: 'Отклонено', icon: 'x-circle', color: '#64748B' },
|
||||
};
|
||||
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
|
||||
let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
function icons() { if (window.lucide) lucide.createIcons(); }
|
||||
|
||||
/* ── form ── */
|
||||
function renderCatPick() {
|
||||
document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
|
||||
`<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
|
||||
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
|
||||
icons();
|
||||
}
|
||||
function pickCat(k) { _formCat = k; renderCatPick(); }
|
||||
function updCounter() {
|
||||
const n = document.getElementById('wf-title').value.length;
|
||||
document.getElementById('wf-counter').textContent = n + ' / 200';
|
||||
}
|
||||
function toggleForm(forceOpen) {
|
||||
_formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
|
||||
document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
|
||||
const btn = document.getElementById('wq-new-btn');
|
||||
btn.classList.toggle('open', _formOpen);
|
||||
document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
|
||||
// lucide заменяет <i> на <svg> при рендере, поэтому пере-вставляем свежий <i> в контейнер.
|
||||
const ic = document.getElementById('wq-new-ic');
|
||||
if (ic) ic.innerHTML = `<i data-lucide="${_formOpen ? 'chevron-up' : 'plus'}" style="width:15px;height:15px"></i>`;
|
||||
icons();
|
||||
if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
|
||||
}
|
||||
|
||||
async function submitWish() {
|
||||
const title = document.getElementById('wf-title').value.trim();
|
||||
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
|
||||
const btn = document.getElementById('wf-submit');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
|
||||
if (isAdmin && user) { row.author_name = user.name; }
|
||||
_wishes.unshift(row);
|
||||
document.getElementById('wf-title').value = '';
|
||||
document.getElementById('wf-body').value = '';
|
||||
_formCat = 'feature'; renderCatPick(); updCounter();
|
||||
toggleForm(false);
|
||||
LS.toast('Пожелание отправлено — спасибо!', 'success');
|
||||
_statusFilter = null; _catFilter = null;
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
/* ── load + render ── */
|
||||
async function load() {
|
||||
try {
|
||||
const data = await LS.wishesList();
|
||||
_wishes = data.wishes || [];
|
||||
renderAll();
|
||||
} catch (e) {
|
||||
document.getElementById('w-list').innerHTML = `<div class="w-empty"><div class="w-empty-t">Не удалось загрузить</div><div class="w-empty-s">${esc(e.message || '')}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function counts() {
|
||||
const c = {}; ST_ORDER.forEach(s => c[s] = 0);
|
||||
_wishes.forEach(w => { c[w.status] = (c[w.status] || 0) + 1; });
|
||||
return c;
|
||||
}
|
||||
|
||||
function renderAll() { renderStats(); renderSubbar(); renderList(); }
|
||||
|
||||
function renderStats() {
|
||||
const c = counts();
|
||||
const total = _wishes.length;
|
||||
let html = `<button class="wq-stat${!_statusFilter ? ' active' : ''}" style="--sc:#9B5DE5" onclick="setStatus(null)">
|
||||
<span class="wq-stat-lbl">Все</span><span class="wq-stat-num">${total}</span></button>`;
|
||||
html += ST_ORDER.filter(s => c[s] > 0).map(s =>
|
||||
`<button class="wq-stat${_statusFilter === s ? ' active' : ''}" style="--sc:${ST[s].color}" onclick="setStatus('${s}')">
|
||||
<span class="wq-stat-dot"></span><span class="wq-stat-lbl">${ST[s].label}</span><span class="wq-stat-num">${c[s]}</span></button>`).join('');
|
||||
document.getElementById('wq-stats').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSubbar() {
|
||||
const cats = [...new Set(_wishes.map(w => w.category))];
|
||||
const bar = document.getElementById('wq-subbar');
|
||||
// показываем подбар только если есть смысл (несколько категорий или много пожеланий)
|
||||
if (cats.length < 2 && _wishes.length < 4) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = '';
|
||||
document.getElementById('wq-cats').innerHTML = CAT_ORDER.filter(k => cats.includes(k)).map(k =>
|
||||
`<button class="wq-cchip${_catFilter === k ? ' active' : ''}" style="--cc:${CAT[k].color}" onclick="setCat('${k}')">
|
||||
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
|
||||
document.getElementById('wq-search').style.display = _wishes.length >= 4 ? '' : 'none';
|
||||
icons();
|
||||
}
|
||||
|
||||
function setStatus(s) { _statusFilter = (_statusFilter === s) ? null : s; renderAll(); }
|
||||
function setCat(k) { _catFilter = (_catFilter === k) ? null : k; renderAll(); }
|
||||
function onSearch(v) { _q = v.trim().toLowerCase(); renderList(); }
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('w-list');
|
||||
let list = _wishes;
|
||||
if (_statusFilter) list = list.filter(w => w.status === _statusFilter);
|
||||
if (_catFilter) list = list.filter(w => w.category === _catFilter);
|
||||
if (_q) list = list.filter(w =>
|
||||
(w.title || '').toLowerCase().includes(_q) ||
|
||||
(w.body || '').toLowerCase().includes(_q) ||
|
||||
(w.author_name || '').toLowerCase().includes(_q));
|
||||
|
||||
if (!list.length) {
|
||||
const fresh = !_wishes.length;
|
||||
el.innerHTML = `<div class="w-empty">
|
||||
<div class="w-empty-art"><i data-lucide="${fresh ? 'lightbulb' : 'search-x'}"></i></div>
|
||||
<div class="w-empty-t">${fresh ? (isAdmin ? 'Пожеланий пока нет' : 'У вас пока нет пожеланий') : 'Ничего не найдено'}</div>
|
||||
<div class="w-empty-s">${fresh ? (isAdmin ? 'Они появятся здесь, когда пользователи их оставят.' : 'Поделитесь идеей — нажмите «Поделиться идеей» выше.') : 'Попробуйте изменить фильтр или запрос.'}</div>
|
||||
</div>`;
|
||||
icons();
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(cardHtml).join('');
|
||||
icons();
|
||||
}
|
||||
|
||||
function cardHtml(w) {
|
||||
const cat = CAT[w.category] || CAT.other;
|
||||
const st = ST[w.status] || ST.new;
|
||||
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span><span>·</span>` : '';
|
||||
const note = w.admin_note ? `<div class="w-note"><i data-lucide="message-square-reply"></i><div><b>Ответ:</b> ${esc(w.admin_note)}</div></div>` : '';
|
||||
let manage = '';
|
||||
if (isAdmin) {
|
||||
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST[s].label}</option>`).join('');
|
||||
manage = `<div class="w-manage">
|
||||
<select class="w-sel" id="st-${w.id}">${opts}</select>
|
||||
<textarea class="w-note-inp" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
|
||||
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})"><i data-lucide="check" style="width:13px;height:13px"></i> Сохранить</button>
|
||||
<button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
|
||||
</div>`;
|
||||
} else if (w.status === 'new') {
|
||||
manage = `<div class="w-manage"><button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Удалить</button></div>`;
|
||||
}
|
||||
return `<div class="w-card" style="--cc:${cat.color}">
|
||||
<div class="w-cat-ic"><i data-lucide="${cat.icon}"></i></div>
|
||||
<div class="w-main">
|
||||
<div class="w-head">
|
||||
<span class="w-title">${esc(w.title)}</span>
|
||||
<span class="w-badge wb-${w.status}"><i data-lucide="${st.icon}"></i>${st.label}</span>
|
||||
</div>
|
||||
<div class="w-meta">${author}<span>${cat.label}</span><span>·</span><span>${fmtDate(w.created_at)}</span></div>
|
||||
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
|
||||
${note}
|
||||
${manage}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveWish(id) {
|
||||
try {
|
||||
const upd = await LS.wishUpdate(id, {
|
||||
status: document.getElementById('st-' + id).value,
|
||||
admin_note: document.getElementById('note-' + id).value.trim(),
|
||||
});
|
||||
const i = _wishes.findIndex(w => w.id === id);
|
||||
if (i >= 0) { _wishes[i] = { ..._wishes[i], ...upd }; }
|
||||
LS.toast('Сохранено', 'success');
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
async function delWish(id) {
|
||||
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.wishDelete(id);
|
||||
_wishes = _wishes.filter(w => w.id !== id);
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
renderCatPick();
|
||||
load();
|
||||
icons();
|
||||
</script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -190,6 +190,12 @@ async function deleteClass(id) { return req('DELETE', `/classes/
|
||||
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
|
||||
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
|
||||
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
|
||||
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
|
||||
/* ── Пожелания по улучшению ── */
|
||||
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
|
||||
async function wishCreate(data) { return req('POST', '/wishes', data); }
|
||||
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
|
||||
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
|
||||
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
|
||||
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
|
||||
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
|
||||
@@ -826,10 +832,129 @@ async function loadFeatures() {
|
||||
_featuresCache = await apiFetch('/api/features');
|
||||
} catch { _featuresCache = {}; }
|
||||
_gamificationEnabled = _featuresCache.gamification !== false;
|
||||
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
|
||||
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
|
||||
return _featuresCache;
|
||||
}
|
||||
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; }
|
||||
|
||||
/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */
|
||||
const FEATURE_HREFS = {
|
||||
hangman: ['/hangman'],
|
||||
crossword: ['/crossword'],
|
||||
pet: ['/pet'],
|
||||
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
|
||||
collection: ['/collection.html', '/collection'],
|
||||
lab: ['/lab'],
|
||||
knowledge_map: ['/knowledge-map'],
|
||||
flashcards: ['/flashcards'],
|
||||
board: ['/board'],
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
classroom: ['/classroom'],
|
||||
sim_builder: ['/sim-builder', '/sim-builder.html'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
quantik: ['/quantik', '/quantik.html'],
|
||||
theory: ['/theory', '/theory.html'],
|
||||
sitemap: ['/sitemap', '/sitemap.html'],
|
||||
wishes: ['/wishes', '/wishes.html'],
|
||||
};
|
||||
/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только
|
||||
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
|
||||
Hero-карточки дашборда: у lab JS меняет href на /lab?sim=… → [href="/lab"] не
|
||||
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
|
||||
const FEATURE_WIDGETS = {
|
||||
flashcards: ['#w-flashcard'],
|
||||
// #hc-lab — hero-карточка дашборда; .tb-lab-btn — кнопка «открыть связанную
|
||||
// симуляцию» на карточках каталога учебников (openLabSim → /lab?sim=…). Это
|
||||
// <button onclick>, а не <a href="/lab">, поэтому [href="/lab"] её не ловит.
|
||||
lab: ['#hc-lab', '.tb-lab-btn'],
|
||||
pet: ['#hc-pet'],
|
||||
textbooks: ['#hc-read'],
|
||||
};
|
||||
/* Админ видит и имеет доступ ко ВСЕМУ, даже к отключённым модулям (он ими управляет).
|
||||
Поэтому для админа никакие скрытия/редиректы фич не применяются. getUser() читает
|
||||
localStorage синхронно (определён в начале файла) — работает и на ранней sync-инъекции. */
|
||||
function _isAdminUser() {
|
||||
try { return getUser()?.role === 'admin'; } catch { return false; }
|
||||
}
|
||||
|
||||
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
|
||||
на ранней загрузке (ДО построения сайдбара/виджетов) — против мигания (FOUC),
|
||||
затем обновляется по свежему /api/features. */
|
||||
function _applyFeatureCss(feats) {
|
||||
// Админ — без скрытий: чистим <style> и снимаем kill-switch геймификации.
|
||||
if (_isAdminUser()) {
|
||||
const elA = document.getElementById('ls-feat-hide');
|
||||
if (elA) elA.textContent = '';
|
||||
document.documentElement.classList.remove('no-gamification');
|
||||
return;
|
||||
}
|
||||
const sels = [];
|
||||
if (feats) {
|
||||
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
|
||||
if (feats[key] === false) {
|
||||
hrefs.forEach(h => sels.push(`[href="${h}"]`));
|
||||
(FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Скрытые exam-prep треки (подготовка): кэш хрефов с прошлой загрузки — против мигания.
|
||||
// /api/exam-prep/tracks асинхронен, поэтому держим точный список скрытых ссылок в кэше.
|
||||
try {
|
||||
JSON.parse(localStorage.getItem('ls_examhide') || '[]')
|
||||
.forEach(h => sels.push(`[href="${h}"]`));
|
||||
} catch { /* пусто */ }
|
||||
let css = sels.length ? sels.join(',') + '{display:none !important}' : '';
|
||||
// Геймификация: дублируем kill-switch в инъекцию — для страниц БЕЗ ls.css.
|
||||
// Учебники (frontend/textbooks/*.html) грузят api.js, но НЕ ls.css, поэтому правила
|
||||
// .no-gamification из ls.css туда не доходят, и встроенная XP-механика (data-gamified,
|
||||
// #ach-popup) оставалась видимой. Инъекция работает на любой странице с api.js.
|
||||
if (feats && feats.gamification === false) {
|
||||
css += '.no-gamification [data-gamified],.no-gamification #ach-popup{display:none!important}';
|
||||
}
|
||||
let el = document.getElementById('ls-feat-hide');
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = 'ls-feat-hide';
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
// Геймификация: класс на <html> (доступен раньше body) → kill-switch без мигания.
|
||||
if (feats) document.documentElement.classList.toggle('no-gamification', feats.gamification === false);
|
||||
}
|
||||
/* Ранняя синхронная попытка из кэша прошлой загрузки — нет мигания на повторных заходах.
|
||||
(FEATURE_HREFS — const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */
|
||||
try {
|
||||
const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
|
||||
_applyFeatureCss(_cachedFeats); // применит и кэш фич, и кэш скрытых exam-prep ссылок
|
||||
} catch { /* нет кэша / приватный режим — просто ждём async */ }
|
||||
|
||||
/* Авторитетно подтянуть фичи на страницах БЕЗ сайдбара (учебники, embed): там
|
||||
sidebar.js/hideDisabledFeatures не вызывают loadFeatures, и кэш мог устареть.
|
||||
loadFeatures() кэширует in-memory (дубль-вызов = один fetch) и сам зовёт _applyFeatureCss.
|
||||
Только для залогиненных — иначе на /login apiFetch поймает 401 и зациклит редирект. */
|
||||
try {
|
||||
if (isLoggedIn()) { loadFeatures().catch(() => {}); }
|
||||
} catch { /* defensive */ }
|
||||
|
||||
/* Прячет группы сайдбара (.sb-group), у которых не осталось ни одного видимого пункта,
|
||||
чтобы не висел пустой заголовок-аккордеон (напр. «Практика и игры», когда все
|
||||
модули отключены). Зовётся после построения сайдбара и после hideDisabledFeatures. */
|
||||
function hideEmptySidebarGroups() {
|
||||
document.querySelectorAll('.sb-group').forEach(g => {
|
||||
const body = g.querySelector('.sb-group-body');
|
||||
if (!body) return;
|
||||
let anyVisible = false;
|
||||
body.querySelectorAll('.sb-link').forEach(it => {
|
||||
const cs = getComputedStyle(it);
|
||||
if (cs.display !== 'none' && cs.visibility !== 'hidden') anyVisible = true;
|
||||
});
|
||||
g.style.display = anyVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show board sidebar link only for teachers/admins and students in a class.
|
||||
* Call after LS.initPage(). Uses features cache (_no_class flag).
|
||||
@@ -839,38 +964,25 @@ async function showBoardIfAllowed() {
|
||||
if (!el) return;
|
||||
const user = getUser();
|
||||
if (!user) return;
|
||||
if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; }
|
||||
// Student: check if in a class
|
||||
// Админ видит доску всегда (даже если фича отключена) — он ею управляет.
|
||||
if (user.role === 'admin') { el.style.display = ''; return; }
|
||||
const feats = await loadFeatures();
|
||||
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю.
|
||||
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
|
||||
// иначе она перекрывает скрытие из hideDisabledFeatures().
|
||||
if (feats.board === false) { el.style.display = 'none'; return; }
|
||||
if (user.role === 'teacher') { el.style.display = ''; return; }
|
||||
// Student: check if in a class
|
||||
if (!feats._no_class) el.style.display = '';
|
||||
}
|
||||
|
||||
async function hideDisabledFeatures() {
|
||||
const feats = await loadFeatures();
|
||||
const map = {
|
||||
hangman: ['/hangman'],
|
||||
crossword: ['/crossword'],
|
||||
pet: ['/pet'],
|
||||
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
|
||||
collection: ['/collection.html', '/collection'],
|
||||
lab: ['/lab'],
|
||||
knowledge_map: ['/knowledge-map'],
|
||||
flashcards: ['/flashcards'],
|
||||
board: ['/board'],
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
classroom: ['/classroom'],
|
||||
sim_builder: ['/sim-builder', '/sim-builder.html'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
quantik: ['/quantik', '/quantik.html'],
|
||||
};
|
||||
for (const [key, hrefs] of Object.entries(map)) {
|
||||
const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
|
||||
// Админ видит и открывает всё — никаких скрытий, редиректов и схлопывания групп.
|
||||
if (_isAdminUser()) return;
|
||||
// Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
|
||||
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
|
||||
if (feats[key] === false) {
|
||||
hrefs.forEach(href => {
|
||||
document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none');
|
||||
});
|
||||
// Redirect away if currently on a disabled page
|
||||
const cur = window.location.pathname;
|
||||
if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) {
|
||||
window.location.href = '/dashboard.html';
|
||||
@@ -878,7 +990,7 @@ async function hideDisabledFeatures() {
|
||||
}
|
||||
}
|
||||
if (feats.gamification === false) {
|
||||
document.body.classList.add('no-gamification');
|
||||
document.body.classList.add('no-gamification'); // дубль на body (html-класс ставит _applyFeatureCss)
|
||||
// If student is already viewing achievements or shop tab, redirect to account tab
|
||||
const active = document.querySelector('#tab-achievements.active, #tab-shop.active');
|
||||
if (active) {
|
||||
@@ -894,10 +1006,16 @@ async function hideDisabledFeatures() {
|
||||
try {
|
||||
const data = await apiFetch('/api/exam-prep/tracks');
|
||||
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
|
||||
// Собираем точные хрефы скрытых треков и кэшируем — чтобы на СЛЕДУЮЩЕЙ загрузке
|
||||
// _applyFeatureCss спрятал их синхронно из кэша ещё до сборки сайдбара (без мигания).
|
||||
const hide = [];
|
||||
examLinks.forEach(el => {
|
||||
const m = (el.getAttribute('href') || '').match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (m && !allowed.has(m[1])) el.style.display = 'none';
|
||||
const href = el.getAttribute('href') || '';
|
||||
const m = href.match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (m && !allowed.has(m[1])) hide.push(href);
|
||||
});
|
||||
try { localStorage.setItem('ls_examhide', JSON.stringify(hide)); } catch {}
|
||||
_applyFeatureCss(_featuresCache); // обновить <style> (скрыть запрещённые, ПОКАЗАТЬ снова разрешённые)
|
||||
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
|
||||
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
|
||||
@@ -928,6 +1046,9 @@ async function hideDisabledFeatures() {
|
||||
document.body.classList.add('no-class');
|
||||
document.body.classList.add('no-gamification'); // no class <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> no gamification
|
||||
}
|
||||
|
||||
// В самом конце — после всех скрытий (фичи, exam-prep, no_class) — схлопнуть пустые группы.
|
||||
hideEmptySidebarGroups();
|
||||
}
|
||||
|
||||
/* ── generic authenticated fetch (full path like /api/courses) ─────── */
|
||||
@@ -1029,7 +1150,8 @@ window.LS = {
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal,
|
||||
regenerateInviteCode, classJournal, classOutstanding,
|
||||
wishesList, wishCreate, wishUpdate, wishDelete,
|
||||
joinClass, myClasses, getStudents, classFeed,
|
||||
getAnnouncements, createAnnouncement, deleteAnnouncement,
|
||||
getNotifications, markNotifRead, markAllNotifsRead, connectSSE,
|
||||
@@ -1089,6 +1211,7 @@ window.LS = {
|
||||
loadFeatures,
|
||||
clearFeaturesCache,
|
||||
hideDisabledFeatures,
|
||||
hideEmptySidebarGroups,
|
||||
showBoardIfAllowed,
|
||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
|
||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||
@@ -1854,3 +1977,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Глобальный репортер клиентских ошибок ───────────────────────────────
|
||||
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
|
||||
и шлёт в /api/client-errors → они появляются в админ-вкладке «Ошибки».
|
||||
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
|
||||
(function initClientErrorReporter() {
|
||||
const seen = new Set();
|
||||
let sent = 0; const MAX_PER_PAGE = 15;
|
||||
let inFlight = false;
|
||||
|
||||
function send(payload) {
|
||||
try {
|
||||
if (!isLoggedIn()) return; // отчёты только от залогиненных
|
||||
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
|
||||
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
|
||||
if (seen.has(sig)) return;
|
||||
seen.add(sig); sent++;
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
const token = getToken();
|
||||
fetch(API + '/client-errors', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true, // долетит даже при закрытии вкладки
|
||||
}).catch(function () {}).finally(function () { inFlight = false; });
|
||||
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
|
||||
}
|
||||
|
||||
window.addEventListener('error', function (e) {
|
||||
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
|
||||
if (!e || (!e.message && !e.error)) return;
|
||||
send({
|
||||
kind: 'error',
|
||||
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
|
||||
stack: e.error && e.error.stack ? String(e.error.stack) : null,
|
||||
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
|
||||
url: location.pathname + location.search + location.hash,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', function (e) {
|
||||
const r = e && e.reason;
|
||||
let msg = 'Unhandled promise rejection';
|
||||
let stack = null;
|
||||
if (r) {
|
||||
if (typeof r === 'string') msg = r;
|
||||
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
|
||||
}
|
||||
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||||
${L('/dashboard', 'home', 'Дашборд')}
|
||||
${L('/sitemap', 'map', 'Путеводитель')}
|
||||
${L('/wishes', 'lightbulb', 'Пожелания')}
|
||||
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
|
||||
${G('learning', 'Учебный процесс', `
|
||||
@@ -228,6 +229,9 @@
|
||||
LS.showBoardIfAllowed?.();
|
||||
LS.hideDisabledFeatures?.();
|
||||
LS.notif?.init?.();
|
||||
// Синхронно по кэш-состоянию (CSS уже инъектнут до сборки) — прячем пустые
|
||||
// группы сразу, без мигания; hideDisabledFeatures повторит после свежих данных.
|
||||
LS.hideEmptySidebarGroups?.();
|
||||
}
|
||||
|
||||
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Тригонометрическая окружность — план улучшения (тренажёр темы, без функций)
|
||||
|
||||
Цель: симуляция `frontend/js/labs/trigcircle.js` + панель `frontend/labs-bodies.html` (#sim-trigcircle)
|
||||
покрывает всю школьную тригонометрию НА ОКРУЖНОСТИ. Графики y=f(x) («функции») — вне темы:
|
||||
существующий showGraph оставляем опциональным/скрываемым.
|
||||
|
||||
Архитектура: рукописный canvas-sim (класс TrigCircleSim) + HTML-панель в labs-bodies.html +
|
||||
glue-функции (`_openTrigCircle`, `trigToggle`, `trigGoTo`, `trigReset`, `_trigUpdateUI`) внизу
|
||||
trigcircle.js; регистрация в `_register-all.js` (`trigcircle`). KaTeX, LabFX, _tasks.js доступны.
|
||||
⛔ без eval, без эмодзи (inline SVG .ic), всё аддитивно (не ломать текущий режим).
|
||||
|
||||
## Уже есть
|
||||
Окружность, перетаскиваемая точка, угол °/рад (метки π/6…), sin/cos/tan/cot отрезками (слои),
|
||||
треугольник sin-cos, касательная/котангенс, 16 табличных углов + snap, подсветка четверти,
|
||||
значения дробями (½,√2/2,√3/2,√3/3,√3), stat-bar, опциональный график (= «функции»).
|
||||
|
||||
## Статус (на 2026-06-24) — ВСЕ ОСНОВНЫЕ ФАЗЫ ГОТОВЫ
|
||||
- ✅ Ф1 (углы: ввод, котерминальность, 16 углов, опорный угол, знаки) — d395e10
|
||||
- ✅ Ф2 (точные значения + формулы приведения для текущего угла) — 5eed248
|
||||
- ✅ KaTeX: формулы — cefb5e0; ВСЯ панель (значения, угол, таблица) — 244df71
|
||||
- ✅ Ф3 (знаки по четвертям): _quadSigns на canvas (текущая четверть подсвечена) +
|
||||
панельная строка знаков (Ф1). Доп. работы не потребовалось.
|
||||
- ✅ Ф4 (таблица значений 0–90° на KaTeX, подсветка опорного угла) — fe6df8f
|
||||
- ✅ Ф5 (чётность −α: зеркальная точка + sin/cos/tg(−α) + периоды) — 48158ea;
|
||||
формулы приведения — Ф2.
|
||||
- ✅ Ф6 (простейшие уравнения fn(x)=a: все решения на круге + общая формула KaTeX) — dfa0535
|
||||
- Уже было на canvas до плана: Пифагор (_pythBar), отрезки sin/cos/tg/ctg, координаты
|
||||
точки, табличные точки+snap, опц. график функций.
|
||||
- Осталось (опционально): режимы-вкладки + задания (_tasks.js); sec/csc; два угла (Ф7).
|
||||
|
||||
## Фазы
|
||||
- **Ф1 — Углы и обзор**: тумблер скрыть график (фокус на круге); ввод угла (° и π-доли);
|
||||
полная сетка табличных кнопок (16); опорный (острый) угол; знаки по четвертям в выводе;
|
||||
подсказка котерминальности (+360°k / +2πk).
|
||||
- **Ф2 — Определения / 6 функций**: подписи sin=y, cos=x на осях; слой sec/csc; Пифагор
|
||||
sin²+cos²=1 (гипотенуза=1) с формулой; тумблер «формула значения» (KaTeX).
|
||||
- **Ф3 — Знаки**: режим со знаками +/− sin/cos/tg по четвертям, мнемоника, таблица.
|
||||
- **Ф4 — Особые углы / таблица значений**: оверлей-таблица 0/30/45/60/90… с подсветкой текущего.
|
||||
- **Ф5 — Симметрии и формулы приведения**: чётность (α→−α), приведение (π±α, π/2±α, 2π−α)
|
||||
с анимацией отражения/поворота + KaTeX; период tg/ctg = π.
|
||||
- **Ф6 — Простейшие уравнения**: задаёшь значение → все решения на круге + общая формула
|
||||
(sin α=½ → π/6+2πk, 5π/6+2πk); для tg — шаг π; связка с arcsin/arccos геометрически.
|
||||
- **Ф7 — Два угла (опц.)**: вторая точка β → α±β, формулы сложения.
|
||||
- **Сквозное**: режимы-вкладки (Углы·Определения·Знаки·Особые·Приведение·Уравнения) с краткой
|
||||
теорией и кнопкой «Задание» через _tasks.js; шпаргалка (значения+знаки+тождества+приведение).
|
||||
|
||||
## Проверка каждой фазы
|
||||
node --check; headless-смоук математики (опорный угол, знаки, решения уравнений, приведение)
|
||||
в vm с стабом canvas; коммит+push, без эмодзи, lint.
|
||||
@@ -183,6 +183,30 @@ function Backup-Db {
|
||||
Prune-Backups
|
||||
}
|
||||
|
||||
function Reset-System {
|
||||
Write-Host ''
|
||||
Write-Host ' ============================================================' -ForegroundColor Red
|
||||
Write-Host ' ВНИМАНИЕ: ЧИСТЫЙ ЗАПУСК - НЕОБРАТИМАЯ ОЧИСТКА' -ForegroundColor Red
|
||||
Write-Host ' ============================================================' -ForegroundColor Red
|
||||
Write-Host ' УДАЛЯТСЯ: все пользователи (кроме одного админа), классы,' -ForegroundColor Yellow
|
||||
Write-Host ' задания, сессии, геймификация, уведомления, прогресс, история.' -ForegroundColor Yellow
|
||||
Write-Host ' СОХРАНЯТСЯ: учебники, вопросы, тесты, курсы, уроки, exam-prep,' -ForegroundColor Gray
|
||||
Write-Host ' симуляции, настройки/права и один админ (контент переходит ему).' -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
Write-Host ' План (предпросмотр, без изменений):' -ForegroundColor Cyan
|
||||
try { & node scripts/reset-system.js | Out-Host } catch { Write-Host (' Ошибка плана: ' + $_.Exception.Message) -ForegroundColor Red; return }
|
||||
Write-Host ''
|
||||
$ans = (Read-Host ' Для подтверждения введите СБРОС (иначе отмена)').Trim().ToUpper()
|
||||
if ($ans -ne 'СБРОС' -and $ans -ne 'RESET') { Write-Host ' Отменено.' -ForegroundColor Yellow; return }
|
||||
if (Server-Proc) { Write-Host ' Сервер работает - остановите его ([2]) перед сбросом для надёжности.' -ForegroundColor Yellow }
|
||||
Write-Host ' Шаг 1/2: бэкап БД...' -ForegroundColor Cyan
|
||||
Backup-Db
|
||||
Write-Host ' Шаг 2/2: очистка...' -ForegroundColor Cyan
|
||||
try { & node scripts/reset-system.js --apply --confirm=RESET | Out-Host }
|
||||
catch { Write-Host (' Ошибка сброса: ' + $_.Exception.Message) -ForegroundColor Red; return }
|
||||
Write-Host ' Готово. Перезапустите сервер ([3]). Бэкап сохранён в data\backups.' -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Restore-Db {
|
||||
if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return }
|
||||
$files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
|
||||
@@ -389,6 +413,7 @@ while ($run) {
|
||||
Menu-Row '[5]' 'Применить миграции' '[A]' 'Создать админа'
|
||||
Menu-Row ' ' '' '[W]' 'Сторож (авто-рестарт)'
|
||||
Menu-Row ' ' '' '[E]' 'Ошибки в логах'
|
||||
Menu-Row ' ' '' '[Z]' 'Сброс системы (чистый запуск)'
|
||||
Write-Host ''
|
||||
Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' ''
|
||||
Write-Host ' ' -NoNewline
|
||||
@@ -417,6 +442,7 @@ while ($run) {
|
||||
'^(U|Г)$' { Run-Cmd 'Обновление из репозитория' { Update-FromRepo }; Refresh-Status }
|
||||
'^(W|Ц)$' { Watchdog; Refresh-Status }
|
||||
'^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } }
|
||||
'^(Z|Я)$' { Reset-System; Refresh-Status; Start-Sleep 1 }
|
||||
'^0$' { $run = $false }
|
||||
default { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user