Compare commits
124 Commits
70cf6b3af1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff9900bdcc | |||
| 393de56c42 | |||
| b5916e7f3b | |||
| fb16821b0a | |||
| 2a50ff740a | |||
| 664751e273 | |||
| a7d20a0c90 | |||
| 169501f12a | |||
| db5c6b80ec | |||
| 03c6ebfdce | |||
| fb81beca39 | |||
| 21ffbbfe6c | |||
| 5226deb975 | |||
| 8df7d1713c | |||
| f0af2079c3 | |||
| d07cb2a434 | |||
| 6eaf68a158 | |||
| d5587b4eb1 | |||
| 123200e759 | |||
| aa20892a79 | |||
| 6d600ad576 | |||
| 47d4f71eac | |||
| 277bddf1fd | |||
| 10c9b007d8 | |||
| cd7c75ff08 | |||
| d003a0e100 | |||
| 7cc2a9d526 | |||
| 8c4c9bf04c | |||
| 48a73d9f8e | |||
| 20b8ce2c5b | |||
| c370eaa803 | |||
| 91917f952c | |||
| e38abff02a | |||
| 7c0ccc7282 | |||
| c045ea02b0 | |||
| b310029e8d | |||
| ee740817a8 | |||
| 29fc270c0e | |||
| 7b4a274aed | |||
| 4c9656e8a8 | |||
| b6c08f1b16 | |||
| c3be921dfb | |||
| 368cf30d58 | |||
| 81bf5d75eb | |||
| f37796f07b | |||
| 1915026bff | |||
| 36eb0b980b | |||
| 08da26afca | |||
| 64ea552cf8 | |||
| 86b2ac1e2d | |||
| 4be7f9a07c | |||
| 7829360391 | |||
| 09dc62dc96 | |||
| 8303d483cc | |||
| 59ea5e7d65 | |||
| 254f373522 | |||
| 70e1b0db53 | |||
| cd0ce17a60 | |||
| fa29332bcd | |||
| 000e42f9b3 | |||
| ce4f1dcec1 | |||
| 9858108556 | |||
| e53c107d83 | |||
| c49077abbc | |||
| 78aea47619 | |||
| bc0ed1892f | |||
| 40c3152fe8 | |||
| 2506a72806 | |||
| 089f93b8ee | |||
| 5b4d9324a4 | |||
| 4d2d02f080 | |||
| d15c15ef2a | |||
| dc5501d723 | |||
| 4c1ce8394c | |||
| 0640efc82c | |||
| 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,146 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Ремонт банка вопросов через ИИ (шлюз Kilo из настроек ассистента).
|
||||||
|
Режимы:
|
||||||
|
--broken починить single/multiple без отмеченного верного варианта
|
||||||
|
(ИИ выбирает верный среди СУЩЕСТВУЮЩИХ вариантов)
|
||||||
|
--topics привязать математические вопросы без темы к СУЩЕСТВУЮЩИМ темам
|
||||||
|
Поток: по умолчанию DRY-RUN — пишет предложения в JSON + сводку, БД не трогает.
|
||||||
|
С флагом --apply — применяет ранее сгенерированный JSON к БД.
|
||||||
|
--limit N ограничить число вопросов (для теста)
|
||||||
|
Пример:
|
||||||
|
node fix-question-bank.js --topics # сгенерировать предложения
|
||||||
|
node fix-question-bank.js --topics --apply # применить после вычитки
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DB_PATH = path.resolve(__dirname, '../data/learnspace.db');
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const MODE = argv.includes('--broken') ? 'broken' : argv.includes('--topics') ? 'topics' : null;
|
||||||
|
const APPLY = argv.includes('--apply');
|
||||||
|
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i >= 0 ? Number(argv[i + 1]) || 0 : 0; })();
|
||||||
|
if (!MODE) { console.error('Укажите режим: --broken или --topics (опц. --apply, --limit N)'); process.exit(1); }
|
||||||
|
const OUT = path.join(__dirname, `_qbank_proposals_${MODE}.json`);
|
||||||
|
|
||||||
|
const db = new DatabaseSync(DB_PATH);
|
||||||
|
try { db.exec('PRAGMA busy_timeout=8000'); } catch (e) {}
|
||||||
|
// node:sqlite (DatabaseSync) НЕ имеет .transaction() — оборачиваем вручную.
|
||||||
|
function runTx(fn) { db.exec('BEGIN'); try { fn(); db.exec('COMMIT'); } catch (e) { try { db.exec('ROLLBACK'); } catch (_) {} throw e; } }
|
||||||
|
|
||||||
|
/* ── провайдер Kilo ── */
|
||||||
|
function aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
|
||||||
|
function pickProvider() {
|
||||||
|
let arr = []; try { arr = JSON.parse(aset('assistant_providers') || '[]'); } catch (e) {}
|
||||||
|
const active = arr.find(p => p.id === aset('assistant_active'));
|
||||||
|
return (active && active.key && active) || arr.find(p => p.key) || null;
|
||||||
|
}
|
||||||
|
const PROV = pickProvider();
|
||||||
|
if (!APPLY && !PROV) { console.error('Нет провайдера ИИ с ключом (настрой в /admin#assistant).'); process.exit(1); }
|
||||||
|
|
||||||
|
async function llm(messages, maxTokens) {
|
||||||
|
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 40000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(PROV.url, {
|
||||||
|
method: 'POST', signal: ctrl.signal,
|
||||||
|
headers: Object.assign({ 'Content-Type': 'application/json' }, PROV.key ? { Authorization: 'Bearer ' + PROV.key } : {}),
|
||||||
|
body: JSON.stringify({ model: PROV.model, temperature: 0.1, max_tokens: maxTokens || 1500, messages }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return { err: 'HTTP ' + r.status };
|
||||||
|
const j = await r.json();
|
||||||
|
return { text: (j.choices && j.choices[0] && j.choices[0].message && (j.choices[0].message.content || j.choices[0].message.reasoning)) || '' };
|
||||||
|
} catch (e) { return { err: e.name === 'AbortError' ? 'timeout' : 'network' }; }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
function parseJson(raw) {
|
||||||
|
let s = String(raw || '').replace(/```(?:json)?/gi, '').trim();
|
||||||
|
const a = s.search(/[[{]/); if (a > 0) s = s.slice(a);
|
||||||
|
const lastArr = s.lastIndexOf(']'), lastObj = s.lastIndexOf('}');
|
||||||
|
const end = Math.max(lastArr, lastObj); if (end >= 0) s = s.slice(0, end + 1);
|
||||||
|
try { return JSON.parse(s); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ═══ APPLY: применить сохранённые предложения ═══ */
|
||||||
|
function applyProposals() {
|
||||||
|
if (!fs.existsSync(OUT)) { console.error('Нет файла предложений ' + OUT + ' — сначала запусти без --apply.'); process.exit(1); }
|
||||||
|
const props = JSON.parse(fs.readFileSync(OUT, 'utf8'));
|
||||||
|
let n = 0;
|
||||||
|
if (MODE === 'broken') {
|
||||||
|
const upd = db.prepare('UPDATE options SET is_correct = 1 WHERE id = ? AND question_id = ?');
|
||||||
|
runTx(() => { for (const p of props) { if (p.optionId) { upd.run(p.optionId, p.id); n++; } } });
|
||||||
|
console.log(`Применено: отмечено верных вариантов — ${n}`);
|
||||||
|
} else {
|
||||||
|
const upd = db.prepare('UPDATE questions SET topic_id = ? WHERE id = ? AND topic_id IS NULL');
|
||||||
|
runTx(() => { for (const p of props) { if (p.topicId) { upd.run(p.topicId, p.id); n++; } } });
|
||||||
|
console.log(`Применено: привязано тем — ${n}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (APPLY) applyProposals();
|
||||||
|
|
||||||
|
/* ═══ DRY-RUN: сгенерировать предложения ═══ */
|
||||||
|
(async () => {
|
||||||
|
if (MODE === 'broken') {
|
||||||
|
let qs = db.prepare(`
|
||||||
|
SELECT q.id, q.text FROM questions q
|
||||||
|
WHERE q.type IN ('single','multiple')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id AND o.is_correct=1)
|
||||||
|
AND EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id)`).all();
|
||||||
|
if (LIMIT) qs = qs.slice(0, LIMIT);
|
||||||
|
console.log(`Битых MCQ к разбору: ${qs.length}`);
|
||||||
|
const props = [];
|
||||||
|
for (const q of qs) {
|
||||||
|
const opts = db.prepare('SELECT id, text FROM options WHERE question_id=? ORDER BY order_index').all(q.id);
|
||||||
|
const list = opts.map((o, i) => `${i + 1}. ${o.text}`).join('\n');
|
||||||
|
const sys = 'Ты эксперт-предметник. Определи ЕДИНСТВЕННЫЙ правильный вариант ответа. ' +
|
||||||
|
'Верни СТРОГО JSON {"correct": N} где N — номер варианта (1..K), либо {"correct": 0} если определить нельзя (например, нужен рисунок/график). Только JSON.';
|
||||||
|
const user = `Вопрос: ${q.text}\n\nВарианты:\n${list}`;
|
||||||
|
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 300);
|
||||||
|
const j = r.text ? parseJson(r.text) : null;
|
||||||
|
const n = j && Number(j.correct);
|
||||||
|
const opt = (n >= 1 && n <= opts.length) ? opts[n - 1] : null;
|
||||||
|
props.push({ id: q.id, optionId: opt ? opt.id : null, optionText: opt ? opt.text : null, q: q.text.slice(0, 70), err: r.err || null });
|
||||||
|
console.log(` #${q.id}: ${opt ? 'верный → «' + String(opt.text).slice(0, 40) + '»' : (r.err || 'не определено (ручная проверка)')}`);
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
|
||||||
|
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --broken --apply`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// topics: математика, вопросы без темы → существующие темы
|
||||||
|
const subj = db.prepare("SELECT id FROM subjects WHERE name LIKE '%атематик%' OR slug='math'").get();
|
||||||
|
if (!subj) { console.error('Предмет «Математика» не найден'); process.exit(1); }
|
||||||
|
const topics = db.prepare('SELECT id, name FROM topics WHERE subject_id=? ORDER BY id').all(subj.id);
|
||||||
|
if (!topics.length) { console.error('У математики нет тем'); process.exit(1); }
|
||||||
|
let qs = db.prepare('SELECT id, text FROM questions WHERE subject_id=? AND topic_id IS NULL').all(subj.id);
|
||||||
|
if (LIMIT) qs = qs.slice(0, LIMIT);
|
||||||
|
console.log(`Тем: ${topics.length} | вопросов без темы: ${qs.length}`);
|
||||||
|
const topicList = topics.map((t, i) => `${i + 1}. ${t.name}`).join('\n');
|
||||||
|
const props = []; const BATCH = 12;
|
||||||
|
for (let i = 0; i < qs.length; i += BATCH) {
|
||||||
|
const chunk = qs.slice(i, i + BATCH);
|
||||||
|
const sys = 'Ты классифицируешь вопросы по математике по СУЩЕСТВУЮЩИМ темам из списка. ' +
|
||||||
|
'Для каждого вопроса верни номер наиболее подходящей темы или 0, если ни одна явно не подходит. ' +
|
||||||
|
'Верни СТРОГО JSON-массив [{"id":<id вопроса>,"t":<номер темы 1..K или 0>}]. Только JSON.';
|
||||||
|
const user = `Темы:\n${topicList}\n\nВопросы:\n` + chunk.map(q => `[id ${q.id}] ${String(q.text).replace(/\s+/g, ' ').slice(0, 240)}`).join('\n');
|
||||||
|
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 900);
|
||||||
|
const arr = r.text ? parseJson(r.text) : null;
|
||||||
|
const map = {}; if (Array.isArray(arr)) arr.forEach(x => { if (x && x.id != null) map[x.id] = Number(x.t); });
|
||||||
|
for (const q of chunk) {
|
||||||
|
const n = map[q.id]; const t = (n >= 1 && n <= topics.length) ? topics[n - 1] : null;
|
||||||
|
props.push({ id: q.id, topicId: t ? t.id : null, topicName: t ? t.name : null, q: String(q.text).replace(/\s+/g, ' ').slice(0, 70) });
|
||||||
|
}
|
||||||
|
const done = Math.min(i + BATCH, qs.length);
|
||||||
|
console.log(` ${done}/${qs.length}${r.err ? ' (ошибка батча: ' + r.err + ')' : ''}`);
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
const assigned = props.filter(p => p.topicId).length;
|
||||||
|
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
|
||||||
|
const byTopic = {}; props.forEach(p => { if (p.topicName) byTopic[p.topicName] = (byTopic[p.topicName] || 0) + 1; });
|
||||||
|
console.log(`\nРазмечено: ${assigned}/${props.length} (остальные — без уверенной темы, останутся как есть)`);
|
||||||
|
Object.entries(byTopic).sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([t, n]) => console.log(` ${t}: ${n}`));
|
||||||
|
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --topics --apply`);
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Авто-здоровье LLM-провайдеров Квантика: периодический пинг каждого провайдера
|
||||||
|
* (lightweight pingLLM) + авто-понижение активного, если он стабильно не отвечает,
|
||||||
|
* а есть здоровый запасной. Результат — в app_settings.assistant_health (JSON-карта
|
||||||
|
* { id: { ok, at, error, ms, fails } }). Авто-переключение пишет тот же
|
||||||
|
* assistant_failover, что показывает баннер в админке. Период — 15 мин (вкл. по
|
||||||
|
* умолчанию; app_settings.assistant_health_enabled='0' выключает). */
|
||||||
|
const db = require('./db/db');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
|
function _get(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
|
||||||
|
function _set(k, v) { db.prepare('INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)').run(k, v); }
|
||||||
|
function _providers() { try { return JSON.parse(_get('assistant_providers') || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function _enabled() { return _get('assistant_health_enabled') !== '0'; }
|
||||||
|
function _noKey(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || '') || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
|
||||||
|
|
||||||
|
async function runHealthCheck() {
|
||||||
|
if (!_enabled()) return { skipped: true };
|
||||||
|
const provs = _providers();
|
||||||
|
if (!provs.length) return { providers: 0 };
|
||||||
|
const a = require('./controllers/assistantController');
|
||||||
|
let prev = {}; try { prev = JSON.parse(_get('assistant_health') || '{}') || {}; } catch (e) {}
|
||||||
|
const health = {};
|
||||||
|
for (const p of provs) {
|
||||||
|
// нет ключа и не keyless-шлюз — не пингуем (в FAQ-режиме), помечаем как «нет ключа»
|
||||||
|
if (!p.key && !_noKey(p.url)) { health[p.id] = { ok: false, at: new Date().toISOString(), error: 'нет ключа', ms: 0, fails: (prev[p.id] && prev[p.id].fails || 0) }; continue; }
|
||||||
|
const t0 = Date.now();
|
||||||
|
let r; try { r = await a.pingLLM({ url: p.url, model: p.model, key: p.key }); } catch (e) { r = { ok: false, error: 'сбой' }; }
|
||||||
|
const ok = !!(r && r.ok);
|
||||||
|
health[p.id] = {
|
||||||
|
ok, at: new Date().toISOString(), ms: Date.now() - t0,
|
||||||
|
error: ok ? null : String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 140),
|
||||||
|
fails: ok ? 0 : ((prev[p.id] && prev[p.id].fails || 0) + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_set('assistant_health', JSON.stringify(health));
|
||||||
|
|
||||||
|
// авто-понижение активного: 2+ подряд неудач И есть здоровый рабочий запасной
|
||||||
|
const activeId = _get('assistant_active');
|
||||||
|
const active = provs.find(p => p.id === activeId);
|
||||||
|
if (active && health[activeId] && !health[activeId].ok && health[activeId].fails >= 2) {
|
||||||
|
const healthy = provs.find(p => p.id !== activeId && health[p.id] && health[p.id].ok && (p.key || _noKey(p.url)));
|
||||||
|
if (healthy) {
|
||||||
|
_set('assistant_active', healthy.id);
|
||||||
|
_set('assistant_failover', JSON.stringify({ at: new Date().toISOString(), failedId: activeId, failedName: active.name, servedId: healthy.id, servedName: healthy.name, reason: 'health', auto: true }));
|
||||||
|
logger.info('assistant-health auto-demote', { from: active.name, to: healthy.name, fails: health[activeId].fails });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { providers: provs.length, health };
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
const run = () => { runHealthCheck().catch(() => {}); };
|
||||||
|
setTimeout(run, 90_000).unref(); // первый прогон через 1.5 мин после старта
|
||||||
|
setInterval(run, 15 * 60 * 1000).unref(); // далее каждые 15 минут
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runHealthCheck, schedule };
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
const { audit } = require('../utils/audit');
|
const { audit } = require('../utils/audit');
|
||||||
const { purgeAccessFor } = require('../services/contentAccess');
|
const { purgeAccessFor } = require('../services/contentAccess');
|
||||||
|
const sysReset = require('../services/systemReset');
|
||||||
|
|
||||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||||
const stmts = {
|
const stmts = {
|
||||||
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
|
|||||||
|
|
||||||
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
|
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
|
||||||
function getAllSessions(req, res) {
|
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 limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
|
||||||
const offset = Math.max(0, Number(req.query.offset) || 0);
|
const offset = Math.max(0, Number(req.query.offset) || 0);
|
||||||
|
|
||||||
const where = ['ts.status = \'completed\''];
|
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
|
||||||
|
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
|
||||||
|
const where = [];
|
||||||
const params = [];
|
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 (subject) { where.push('s.slug = ?'); params.push(subject); }
|
||||||
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
|
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
|
FROM test_sessions ts
|
||||||
LEFT JOIN subjects s ON s.id = ts.subject_id
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
||||||
JOIN users u ON u.id = ts.user_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
|
ORDER BY ts.started_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(...params);
|
`).all(...params);
|
||||||
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
|
|||||||
function updateFeatures(req, res) {
|
function updateFeatures(req, res) {
|
||||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
|
'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 updates = req.body;
|
||||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||||
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||||
@@ -548,13 +556,14 @@ function updateFeatures(req, res) {
|
|||||||
invalidateGamificationCache();
|
invalidateGamificationCache();
|
||||||
} catch { /* defensive — shouldn't fail */ }
|
} catch { /* defensive — shouldn't fail */ }
|
||||||
}
|
}
|
||||||
|
_autoReindexSystem(); // снимок «знаний о системе» подстраивается под новые флаги (если уже индексировали)
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/admin/free-student-features ────────────────────────────── */
|
/* ── GET /api/admin/free-student-features ────────────────────────────── */
|
||||||
const FREE_STUDENT_MODULES = [
|
const FREE_STUDENT_MODULES = [
|
||||||
'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection',
|
'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection',
|
||||||
'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz',
|
'lab', 'quantik', 'knowledge_map', 'flashcards', 'imggen', 'board', 'biochem', 'live_quiz',
|
||||||
];
|
];
|
||||||
function getFreeStudentFeatures(_req, res) {
|
function getFreeStudentFeatures(_req, res) {
|
||||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
|
||||||
@@ -586,6 +595,56 @@ function updateFreeStudentFeatures(req, res) {
|
|||||||
res.json({ ok: true });
|
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 ───────────────────────────────────────── */
|
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
|
||||||
function getAuditLog(req, res) {
|
function getAuditLog(req, res) {
|
||||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
||||||
@@ -659,8 +718,6 @@ function clearSecurityLog(req, res) {
|
|||||||
|
|
||||||
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||||
const sse = require('../sse');
|
const sse = require('../sse');
|
||||||
@@ -879,29 +936,126 @@ function broadcast(req, res) {
|
|||||||
|
|
||||||
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
|
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
|
||||||
const ASSISTANT_PRESETS = [
|
const ASSISTANT_PRESETS = [
|
||||||
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' },
|
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-super-120b-a12b:free' },
|
||||||
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
|
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
|
||||||
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
|
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
|
||||||
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
|
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
|
||||||
|
{ name: 'HuggingFace Router', url: 'https://router.huggingface.co/v1/chat/completions', model: 'Qwen/Qwen2.5-72B-Instruct' },
|
||||||
|
{ name: 'Pollinations (без ключа)', url: 'https://text.pollinations.ai/openai', model: 'openai' },
|
||||||
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
|
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
|
||||||
];
|
];
|
||||||
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
|
|
||||||
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
|
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
|
||||||
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
|
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
|
||||||
|
// Сверено с live-списком шлюза и протестировано на русский 2026-06-24 (% — доля кириллицы в тест-ответе):
|
||||||
|
// owl-alpha 95%, nemotron-super 91%, nano-omni 99%, laguna-xs.2 92%, openrouter/free 100% — чисто;
|
||||||
|
// nemotron-ultra/laguna-m.1 — существуют, но на free-тарифе бывает таймаут; nex-n2-pro удалён со шлюза.
|
||||||
const KILO_MODELS = [
|
const KILO_MODELS = [
|
||||||
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман (1M)', ctx: 1000000, out: 65536 },
|
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс, быстрый (262K)', ctx: 262144, out: 262144 },
|
||||||
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс (1M)', ctx: 1000000, out: 262144 },
|
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048576, out: 262144 },
|
||||||
{ id: 'nex-agi/nex-n2-pro:free', label: 'Nex N2 Pro — чистый русский (262K)', ctx: 262144, out: 65536 },
|
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман, медленный (1M)', ctx: 1000000, out: 65536 },
|
||||||
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048756, out: 262144 },
|
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрый, мультимодальный (256K)', ctx: 256000, out: 65536 },
|
||||||
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрая (256K)', ctx: 256000, out: 65536 },
|
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 (262K)', ctx: 262144, out: 32768 },
|
||||||
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 — быстрая (262K)', ctx: 262144, out: 32768 },
|
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS.2 — лёгкая, быстрая (262K)', ctx: 262144, out: 32768 },
|
||||||
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS — лёгкая (262K)', ctx: 262144, out: 32768 },
|
{ id: 'openrouter/free', label: 'Авто-роутер (бесплатные модели)', ctx: 200000, out: 32768 },
|
||||||
];
|
];
|
||||||
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
|
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
|
||||||
|
|
||||||
|
// Рабочий список бесплатных моделей: обновлённый сканом (app_settings) либо хардкод KILO_MODELS как сид.
|
||||||
|
function _kiloModels() {
|
||||||
|
try { const r = _aset('assistant_kilo_models'); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
|
||||||
|
return KILO_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
|
function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
|
||||||
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
|
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
|
||||||
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
|
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
|
||||||
|
// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен.
|
||||||
|
function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
|
||||||
|
|
||||||
|
/* ── Индексация системы: снимок модулей/флагов + описание → знания Квантика ── */
|
||||||
|
// key — имя фича-флага (app_settings.feature_<key>_enabled, по умолч. ВКЛ); null = всегда доступно.
|
||||||
|
const MODULE_CATALOG = [
|
||||||
|
{ key: 'textbooks', name: 'Учебники', url: '/textbooks', desc: 'Главы и параграфы с теорией, формулами и задачами; прогресс чтения.' },
|
||||||
|
{ key: 'exam9', name: 'Подготовка к экзамену', url: '/exam-prep', desc: 'Тесты по темам, режимы экзамена/тренировки/случайный, разбор ошибок (ЦТ/ЦЭ).' },
|
||||||
|
{ key: 'flashcards', name: 'Флешкарты', url: '/flashcards', desc: 'Карточки с интервальным повторением, картинки и формулы KaTeX, общие колоды.' },
|
||||||
|
{ key: 'theory', name: 'Теория', url: '/theory', desc: 'Курсы и уроки с теорией и заданиями; быстрый одиночный урок.' },
|
||||||
|
{ key: 'lab', name: 'Лаборатория', url: '/lab', desc: 'Интерактивные 2D-симуляции по физике/математике/химии прямо в браузере.' },
|
||||||
|
{ key: 'board', name: 'Доска', url: '/board', desc: 'Интерактивная доска: рисование, фигуры, формулы, линейка/транспортир.' },
|
||||||
|
{ key: 'classroom', name: 'Онлайн-урок', url: '/classroom', desc: 'Живой урок с доской, чатом и видео; заметки сохраняются в «Мои материалы».' },
|
||||||
|
{ key: null, name: 'Мои материалы', url: '/my-materials', desc: 'Личное хранилище: вырезки учебника, страницы доски, заметки (с папками и тегами).' },
|
||||||
|
{ key: null, name: 'Домашние задания', url: '/homework', desc: 'Задания, дедлайны, загрузка выполненной работы.' },
|
||||||
|
{ key: 'pet', name: 'Питомец Квантик', url: '/pet', desc: 'Виртуальный питомец: растёт от активности, XP/монеты/серии.' },
|
||||||
|
{ key: 'gamification', name: 'Геймификация', url: '/profile', desc: 'XP, уровни, монеты, достижения, стрики, магазин, лидерборд.' },
|
||||||
|
{ key: 'collection', name: 'Коллекция', url: '/collection', desc: 'Коллекционирование предметов/карточек.' },
|
||||||
|
{ key: 'knowledge_map', name: 'Карта знаний', url: '/knowledge-map', desc: 'Граф знаний по темам.' },
|
||||||
|
{ key: 'red_book', name: 'Красная книга', url: '/red-book', desc: 'Виды, биомы, экосистемы и мини-игры.' },
|
||||||
|
{ key: 'biochem', name: 'Биохимия', url: '/biochem', desc: 'Интерактивные молекулы, реакции, пути.' },
|
||||||
|
{ key: 'crossword', name: 'Кроссворд', url: '/crossword', desc: 'Игра-кроссворд по терминам предметов; закрепляет понятия, даёт XP.' },
|
||||||
|
{ key: 'hangman', name: 'Виселица', url: '/hangman', desc: 'Игра «Виселица» по терминам предметов; закрепляет слова, даёт XP.' },
|
||||||
|
{ key: 'quantik', name: 'Квантик: Законы Мира', url: '/quantik', desc: 'Физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс.' },
|
||||||
|
{ key: 'trainer', name: 'Тренажёр', url: '/trainer', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам с мгновенной проверкой ответа и прогрессом по навыкам.' },
|
||||||
|
{ key: 'live_quiz', name: 'Live-викторина', url: '/live-quiz', desc: 'Викторина в реальном времени: учитель запускает, ученики отвечают одновременно.' },
|
||||||
|
{ key: 'sitemap', name: 'Путеводитель', url: '/sitemap', desc: 'Карта-обзор всех разделов платформы со ссылками.' },
|
||||||
|
{ key: 'sim_builder', name: 'Конструктор симуляций', url: '/sim-builder', desc: 'Авторинг 2D-симуляций (учитель/админ).' },
|
||||||
|
{ key: null, name: 'Поиск', url: null, desc: 'Глобальный поиск по платформе (Ctrl+K): уроки, курсы, файлы, вопросы.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function _featFlags() {
|
||||||
|
const map = {};
|
||||||
|
try { db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all()
|
||||||
|
.forEach(r => { map[r.key.replace('feature_', '').replace('_enabled', '')] = (r.value !== '0' && r.value !== 'false'); }); } catch (e) {}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собрать фрагменты знаний о системе: статус модулей + сводка + описание админа.
|
||||||
|
function buildSystemKb() {
|
||||||
|
const flags = _featFlags();
|
||||||
|
const en = (k) => (k ? (flags[k] !== false) : true); // нет флага → доступно по умолчанию
|
||||||
|
const chunks = [];
|
||||||
|
const on = [], off = [];
|
||||||
|
MODULE_CATALOG.forEach(m => {
|
||||||
|
const e = en(m.key);
|
||||||
|
(e ? on : off).push(m.name);
|
||||||
|
chunks.push({ title: m.name, text: `Модуль «${m.name}» — ${e ? 'ВКЛЮЧЁН и доступен' : 'ВЫКЛЮЧЕН (не предлагать ученику)'}. ${m.desc}${m.url ? ' Раздел: ' + m.url + '.' : ''}` });
|
||||||
|
});
|
||||||
|
chunks.push({ title: 'Доступные модули', text: `Сейчас на платформе ВКЛЮЧЕНЫ разделы: ${on.join(', ')}.` + (off.length ? ` ВЫКЛЮЧЕНЫ (о них не рассказывать и не предлагать): ${off.join(', ')}.` : '') });
|
||||||
|
// авто-подхват НОВЫХ модулей: любой фича-флаг, которого нет в каталоге (assistant — это сам помощник, пропускаем)
|
||||||
|
const known = new Set(MODULE_CATALOG.map(m => m.key).filter(Boolean));
|
||||||
|
Object.keys(flags).forEach(k => {
|
||||||
|
if (known.has(k) || k === 'assistant') return;
|
||||||
|
chunks.push({ title: 'Функция: ' + k, text: `Функция платформы «${k}» — ${flags[k] !== false ? 'ВКЛЮЧЕНА' : 'ВЫКЛЮЧЕНА (не предлагать)'}.` });
|
||||||
|
});
|
||||||
|
const doc = _aset('assistant_system_doc');
|
||||||
|
if (doc && doc.trim()) doc.split(/\n{2,}/).map(s => s.trim()).filter(Boolean).forEach(p => chunks.push({ title: 'Описание системы', text: p.slice(0, 1500) }));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тихая авто-переиндексация (вызывается при смене фича-флагов). Не создаёт KB,
|
||||||
|
// если админ ещё ни разу не индексировал — только обновляет существующий снимок.
|
||||||
|
function _autoReindexSystem() {
|
||||||
|
try {
|
||||||
|
if (!_aset('assistant_system_kb')) return;
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb', ?)").run(JSON.stringify(buildSystemKb()));
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb_at', ?)").run(new Date().toISOString());
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модули с фича-флагом, но без записи в каталоге (нет «красивого» описания) — подсказка админу.
|
||||||
|
function _undocumentedModules() {
|
||||||
|
try { const known = new Set(MODULE_CATALOG.map(m => m.key).filter(Boolean)); return Object.keys(_featFlags()).filter(k => !known.has(k) && k !== 'assistant'); }
|
||||||
|
catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/admin/assistant/index-system — пересобрать знания о системе */
|
||||||
|
function indexSystem(req, res) {
|
||||||
|
try {
|
||||||
|
const chunks = buildSystemKb();
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb', ?)").run(JSON.stringify(chunks));
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb_at', ?)").run(new Date().toISOString());
|
||||||
|
audit(req, 'assistant.index_system', 'assistant', chunks.length + ' фрагментов');
|
||||||
|
res.json({ ok: true, count: chunks.length });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message || 'ошибка индексации' }); }
|
||||||
|
}
|
||||||
|
|
||||||
function getAssistant(_req, res) {
|
function getAssistant(_req, res) {
|
||||||
// Миграция legacy-настроек в список провайдеров (один раз)
|
// Миграция legacy-настроек в список провайдеров (один раз)
|
||||||
@@ -915,10 +1069,10 @@ function getAssistant(_req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const list = _aProviders();
|
const list = _aProviders();
|
||||||
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
|
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, noKey: _aNoKey(p.url), ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
|
||||||
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
|
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
|
||||||
const ap = list.find(p => p.id === activeId);
|
const ap = list.find(p => p.id === activeId);
|
||||||
const active = !!(ap && (ap.key || _aIsLocal(ap.url)));
|
const active = !!(ap && (ap.key || _aNoKey(ap.url)));
|
||||||
|
|
||||||
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
|
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
|
||||||
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
|
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
|
||||||
@@ -939,8 +1093,15 @@ function getAssistant(_req, res) {
|
|||||||
res.json({
|
res.json({
|
||||||
providers, activeId, active,
|
providers, activeId, active,
|
||||||
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
||||||
memory: _aset('assistant_memory') !== '0',
|
memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1',
|
||||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
|
healthEnabled: _aset('assistant_health_enabled') !== '0',
|
||||||
|
health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(),
|
||||||
|
systemDoc: _aset('assistant_system_doc') || '',
|
||||||
|
systemKbCount: (() => { try { return (JSON.parse(_aset('assistant_system_kb') || '[]') || []).length; } catch (e) { return 0; } })(),
|
||||||
|
systemKbAt: _aset('assistant_system_kb_at') || null,
|
||||||
|
systemUndoc: _undocumentedModules(),
|
||||||
|
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
|
||||||
|
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +1112,9 @@ function saveAssistant(req, res) {
|
|||||||
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
|
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
|
||||||
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
|
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
|
||||||
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
|
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
|
||||||
|
if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0');
|
||||||
|
if (typeof b.healthEnabled === 'boolean') set('assistant_health_enabled', b.healthEnabled ? '1' : '0');
|
||||||
|
if (typeof b.systemDoc === 'string') set('assistant_system_doc', b.systemDoc.slice(0, 8000));
|
||||||
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
|
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
|
||||||
audit(req, 'assistant.config', 'assistant', 'настройки');
|
audit(req, 'assistant.config', 'assistant', 'настройки');
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
@@ -1050,6 +1214,105 @@ async function getProviderModels(req, res) {
|
|||||||
res.json({ models: r.models, current });
|
res.json({ models: r.models, current });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Сканер бесплатных моделей шлюза (наполняет список KILO_MODELS) ───────── */
|
||||||
|
// Заведомо не-чат модели (музыка/картинки/эмбеддинги/модерация) — не тестируем.
|
||||||
|
const _NONCHAT_RE = /(lyria|whisper|tts|embed|rerank|moderation|content-safety|guard|dall-?e|imagen|sora|veo|\bmusic\b)/i;
|
||||||
|
|
||||||
|
// Kilo-провайдер (со шлюзом kilocode.ai): по id, иначе активный, иначе первый с ключом.
|
||||||
|
function _pickKiloProvider(id) {
|
||||||
|
const arr = _aProviders();
|
||||||
|
if (id) return arr.find(p => p.id === id) || null;
|
||||||
|
const active = arr.find(p => p.id === _aset('assistant_active'));
|
||||||
|
if (active && /kilocode\.ai/.test(active.url || '') && active.key) return active;
|
||||||
|
return arr.find(p => /kilocode\.ai/.test(p.url || '') && p.key)
|
||||||
|
|| arr.find(p => /kilocode\.ai/.test(p.url || '')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/admin/assistant/scan { id? } — найти бесплатные модели на шлюзе провайдера.
|
||||||
|
* Без инференса: список + сверка с текущим рабочим списком (что новое / что исчезло). */
|
||||||
|
async function scanModels(req, res) {
|
||||||
|
const prov = _pickKiloProvider(req.body && req.body.id);
|
||||||
|
if (!prov) return res.json({ error: 'Нет Kilo-провайдера. Добавьте провайдера со шлюзом kilocode.ai с ключом.' });
|
||||||
|
const r = await _fetchModels(prov.url, prov.key);
|
||||||
|
if (r.error) return res.json({ error: r.error, status: r.status });
|
||||||
|
const cur = _kiloModels();
|
||||||
|
const curIds = new Set(cur.map(m => m.id));
|
||||||
|
const liveIds = new Set(r.models.map(m => m.id));
|
||||||
|
const free = r.models
|
||||||
|
.filter(m => (m.free === true || /:free$/.test(m.id)) && !_NONCHAT_RE.test(m.id))
|
||||||
|
.map(m => ({ id: m.id, ctx: m.ctx, out: m.out, status: curIds.has(m.id) ? 'current' : 'new' }));
|
||||||
|
free.sort((a, b) => (a.status === b.status ? (b.ctx || 0) - (a.ctx || 0) : a.status === 'current' ? -1 : 1));
|
||||||
|
const gone = cur.filter(m => !liveIds.has(m.id)).map(m => ({ id: m.id, label: m.label }));
|
||||||
|
res.json({ providerId: prov.id, providerName: prov.name, total: r.models.length, models: free, gone, current: cur });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/admin/assistant/probe { id?, model } — один тест-запрос на русском. */
|
||||||
|
async function probeModel(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const prov = _pickKiloProvider(b.id);
|
||||||
|
if (!prov) return res.json({ ok: false, error: 'нет провайдера' });
|
||||||
|
const model = String(b.model || '').trim().slice(0, 120);
|
||||||
|
if (!model) return res.json({ ok: false, error: 'нет модели' });
|
||||||
|
if (typeof fetch !== 'function') return res.json({ ok: false, error: 'fetch недоступен' });
|
||||||
|
const PROMPT = 'Ученик 9 класса спрашивает: что такое синус острого угла в прямоугольном треугольнике? Объясни кратко и понятно. Отвечай только на русском языке.';
|
||||||
|
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 22000);
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const r = await fetch(prov.url, {
|
||||||
|
method: 'POST', signal: ctrl.signal,
|
||||||
|
headers: Object.assign({ 'Content-Type': 'application/json' }, prov.key ? { Authorization: 'Bearer ' + prov.key } : {}),
|
||||||
|
body: JSON.stringify({ model, max_tokens: 160, temperature: 0.3, messages: [{ role: 'user', content: PROMPT }] }),
|
||||||
|
});
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
const txt = await r.text();
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = txt.slice(0, 200);
|
||||||
|
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 200); } catch (e) {}
|
||||||
|
return res.json({ ok: false, status: r.status, ms, error: msg });
|
||||||
|
}
|
||||||
|
let sample = '';
|
||||||
|
try { const j = JSON.parse(txt); const m = j.choices && j.choices[0] && j.choices[0].message; sample = String((m && (m.content || m.reasoning)) || '').trim(); } catch (e) {}
|
||||||
|
const letters = (sample.match(/[A-Za-zА-Яа-яЁё一-鿿]/g) || []).length;
|
||||||
|
const cyr = (sample.match(/[А-Яа-яЁё]/g) || []).length;
|
||||||
|
const cjk = (sample.match(/[一-鿿]/g) || []).length;
|
||||||
|
const ratio = letters ? cyr / letters : 0;
|
||||||
|
const verdict = !sample ? 'пусто' : cjk > 0 ? 'иероглифы' : ratio > 0.55 ? 'чистый русский' : ratio > 0.2 ? 'смешанный' : 'не русский';
|
||||||
|
res.json({ ok: true, ms, ratio: Math.round(ratio * 100), cjk, verdict, sample: sample.replace(/\s+/g, ' ').slice(0, 180) });
|
||||||
|
} catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: e.name === 'AbortError' ? 'таймаут' : 'сеть' }); }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/admin/assistant/models/apply { models:[{id,label,ctx,out}] | reset:true } */
|
||||||
|
function applyModels(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
if (b.reset) {
|
||||||
|
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_kilo_models'").run(); } catch (e) {}
|
||||||
|
audit(req, 'assistant.models', 'kilo', 'сброс к встроенному');
|
||||||
|
return res.json({ ok: true, reset: true });
|
||||||
|
}
|
||||||
|
const arr = Array.isArray(b.models) ? b.models : null;
|
||||||
|
if (!arr) return res.status(400).json({ error: 'models[] обязателен' });
|
||||||
|
const clean = [];
|
||||||
|
for (const m of arr.slice(0, 40)) {
|
||||||
|
const id = String((m && m.id) || '').trim().slice(0, 120);
|
||||||
|
if (!id) continue;
|
||||||
|
clean.push({ id, label: String((m && m.label) || id).trim().slice(0, 80), ctx: Number(m && m.ctx) || null, out: Number(m && m.out) || null });
|
||||||
|
}
|
||||||
|
if (!clean.length) return res.status(400).json({ error: 'пустой список' });
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_kilo_models', ?)").run(JSON.stringify(clean));
|
||||||
|
audit(req, 'assistant.models', 'kilo', clean.length + ' моделей');
|
||||||
|
res.json({ ok: true, count: clean.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/admin/assistant/health — прогнать проверку здоровья провайдеров сейчас */
|
||||||
|
async function runHealth(req, res) {
|
||||||
|
try {
|
||||||
|
const r = await require('../assistant-health').runHealthCheck();
|
||||||
|
audit(req, 'assistant.health', 'assistant', 'ручная проверка');
|
||||||
|
res.json({ ok: true, result: r });
|
||||||
|
} catch (e) { res.status(500).json({ ok: false, error: e.message || 'ошибка' }); }
|
||||||
|
}
|
||||||
|
|
||||||
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
|
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
|
||||||
function setActiveProvider(req, res) {
|
function setActiveProvider(req, res) {
|
||||||
const id = String((req.body && req.body.id) || '');
|
const id = String((req.body && req.body.id) || '');
|
||||||
@@ -1087,7 +1350,7 @@ async function testAssistant(req, res) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
override.local = _aIsLocal(override.url);
|
override.local = _aIsLocal(override.url);
|
||||||
override.on = !!(override.key || override.local);
|
override.on = !!(override.key || _aNoKey(override.url));
|
||||||
const r = await a.pingLLM(override);
|
const r = await a.pingLLM(override);
|
||||||
// Успешный тест активного провайдера снимает устаревший флаг failover
|
// Успешный тест активного провайдера снимает устаревший флаг failover
|
||||||
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
|
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
|
||||||
@@ -1157,9 +1420,11 @@ module.exports = {
|
|||||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||||
|
getResetPlan, resetSystem,
|
||||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||||
getSecurityLog, clearSecurityLog,
|
getSecurityLog, clearSecurityLog,
|
||||||
getTopics, createTopic, updateTopic, deleteTopic,
|
getTopics, createTopic, updateTopic, deleteTopic,
|
||||||
broadcast,
|
broadcast,
|
||||||
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
|
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
|
||||||
|
scanModels, probeModel, applyModels, runHealth, indexSystem,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const db = require('../db/db');
|
|||||||
const { pushNotif } = require('../utils/notifications');
|
const { pushNotif } = require('../utils/notifications');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
const { SESSION_MODES } = require('../constants');
|
const { SESSION_MODES } = require('../constants');
|
||||||
|
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
|
||||||
|
|
||||||
const VALID_ASSIGN_MODES = SESSION_MODES;
|
const VALID_ASSIGN_MODES = SESSION_MODES;
|
||||||
|
|
||||||
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
|
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
|
||||||
function myAssignments(req, res) {
|
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
|
||||||
const uid = req.user.id;
|
function assignmentRowsForUser(uid) {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
|
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,
|
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,
|
tp.paragraphs_read AS textbook_read,
|
||||||
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
|
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
|
||||||
|
a.created_by AS created_by,
|
||||||
latest.session_id,
|
latest.session_id,
|
||||||
ts.score, ts.total, ts.status AS session_status,
|
ts.score, ts.total, ts.status AS session_status,
|
||||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
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,
|
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,
|
tp.paragraphs_read AS textbook_read,
|
||||||
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
|
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
|
||||||
|
a.created_by AS created_by,
|
||||||
latest.session_id,
|
latest.session_id,
|
||||||
ts.score, ts.total, ts.status AS session_status,
|
ts.score, ts.total, ts.status AS session_status,
|
||||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
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)
|
// Strip raw paragraphs_read JSON from response (not needed by client)
|
||||||
delete r.textbook_read;
|
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.
|
/* 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,
|
deleteAssignment,
|
||||||
teacherAssignments,
|
teacherAssignments,
|
||||||
myAssignments,
|
myAssignments,
|
||||||
|
classOutstanding,
|
||||||
startAssignment,
|
startAssignment,
|
||||||
assignmentResults,
|
assignmentResults,
|
||||||
assignmentQuestionStats,
|
assignmentQuestionStats,
|
||||||
|
|||||||
@@ -153,25 +153,54 @@ function weakSubject(uid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Долгая память об ученике ─────────────────────────────────────────── */
|
/* ── Долгая память об ученике ─────────────────────────────────────────── */
|
||||||
|
// Темы экзамена хранятся англ. ключами (exam_tasks.topic) — показываем по-русски.
|
||||||
|
const _EXAM_TOPIC_RU = {
|
||||||
|
algebra: 'Алгебра', equations: 'Уравнения и неравенства', planimetry: 'Планиметрия',
|
||||||
|
geometry: 'Геометрия', 'word-sequences': 'Текстовые задачи', numbers: 'Числа и вычисления',
|
||||||
|
trigonometry: 'Тригонометрия', stereometry: 'Стереометрия', functions: 'Функции',
|
||||||
|
theory: 'Теория вероятностей', expressions: 'Выражения и преобразования', advanced: 'Повышенной сложности'
|
||||||
|
};
|
||||||
// Производный профиль (без LLM) — из уже накопленных сигналов.
|
// Производный профиль (без LLM) — из уже накопленных сигналов.
|
||||||
function _studentProfile(uid) {
|
function _studentProfile(uid) {
|
||||||
const out = { weakSubjects: [], weakTopics: [], exam: null, streak: 0 };
|
const out = { weakSubjects: [], weakTopics: [], exam: null, streak: 0 };
|
||||||
|
// «Забыть всё» ставит точку отсчёта: производный профиль учитывает только активность ПОСЛЕ неё.
|
||||||
|
let forget = null;
|
||||||
|
try { const fr = db.prepare("SELECT value FROM app_settings WHERE key = ?").get('asst_forget_' + uid); forget = (fr && fr.value) || null; } catch (e) {}
|
||||||
try {
|
try {
|
||||||
out.weakSubjects = db.prepare(`
|
out.weakSubjects = db.prepare(`
|
||||||
SELECT s.name AS name, ROUND(AVG(ts.score * 100.0 / ts.total)) AS avg, COUNT(*) AS n
|
SELECT s.name AS name, ROUND(AVG(ts.score * 100.0 / ts.total)) AS avg, COUNT(*) AS n
|
||||||
FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id
|
FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id
|
||||||
WHERE ts.user_id = ? AND ts.status = 'completed' AND ts.total > 0
|
WHERE ts.user_id = ? AND ts.status = 'completed' AND ts.total > 0${forget ? ' AND ts.finished_at > ?' : ''}
|
||||||
GROUP BY ts.subject_id HAVING n >= 2 AND avg < 70 ORDER BY avg ASC LIMIT 3
|
GROUP BY ts.subject_id HAVING n >= 2 AND avg < 70 ORDER BY avg ASC LIMIT 3
|
||||||
`).all(uid).map(r => ({ name: r.name, avg: r.avg }));
|
`).all(...(forget ? [uid, forget] : [uid])).map(r => ({ name: r.name, avg: r.avg }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
out.weakTopics = db.prepare(`
|
const cand = {}; // трудные темы по ВСЕМ предметам: банк тестов + экзамен
|
||||||
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct
|
try {
|
||||||
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id
|
db.prepare(`
|
||||||
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> ''
|
SELECT t.name AS topic, COUNT(*) AS attempts, SUM(ua.is_correct) AS correct
|
||||||
GROUP BY et.topic HAVING attempts >= 3 AND (correct * 1.0 / attempts) < 0.6
|
FROM user_answers ua JOIN questions q ON q.id = ua.question_id JOIN topics t ON t.id = q.topic_id
|
||||||
ORDER BY (correct * 1.0 / attempts) ASC LIMIT 3
|
WHERE ua.session_id IN (SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed')${forget ? ' AND ua.answered_at > ?' : ''}
|
||||||
`).all(uid).map(r => ({ topic: r.topic, rate: Math.round(r.correct * 100 / r.attempts) }));
|
GROUP BY q.topic_id HAVING attempts >= 3
|
||||||
|
`).all(...(forget ? [uid, forget] : [uid])).forEach(r => { cand[r.topic] = { topic: r.topic, attempts: r.attempts, correct: r.correct || 0 }; });
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct
|
||||||
|
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id
|
||||||
|
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> ''${forget ? ' AND ea.created_at > ?' : ''}
|
||||||
|
GROUP BY et.topic HAVING attempts >= 3
|
||||||
|
`).all(...(forget ? [uid, forget] : [uid])).forEach(r => {
|
||||||
|
const topic = _EXAM_TOPIC_RU[r.topic] || r.topic;
|
||||||
|
const c = cand[topic];
|
||||||
|
if (c) { c.attempts += r.attempts; c.correct += (r.correct || 0); }
|
||||||
|
else cand[topic] = { topic: topic, attempts: r.attempts, correct: r.correct || 0 };
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
out.weakTopics = Object.values(cand)
|
||||||
|
.map(c => ({ topic: c.topic, rate: Math.round(c.correct * 100 / c.attempts) }))
|
||||||
|
.filter(x => x.rate < 60)
|
||||||
|
.sort((a, b) => a.rate - b.rate).slice(0, 4);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
const p = db.prepare('SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1').get(uid);
|
const p = db.prepare('SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1').get(uid);
|
||||||
@@ -190,34 +219,80 @@ function _memoryBlock(uid) {
|
|||||||
if (p.weakTopics.length) parts.push('трудные темы: ' + p.weakTopics.map(t => `${t.topic} (${t.rate}%)`).join(', '));
|
if (p.weakTopics.length) parts.push('трудные темы: ' + p.weakTopics.map(t => `${t.topic} (${t.rate}%)`).join(', '));
|
||||||
if (p.streak >= 3) parts.push(`серия занятий ${p.streak} дн.`);
|
if (p.streak >= 3) parts.push(`серия занятий ${p.streak} дн.`);
|
||||||
try {
|
try {
|
||||||
const notes = db.prepare('SELECT text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC LIMIT 8').all(uid).map(r => r.text);
|
const rows = db.prepare('SELECT kind, text, weight, updated_at FROM assistant_memory WHERE user_id = ?').all(uid);
|
||||||
if (notes.length) parts.push('заметки: ' + notes.join('; '));
|
rows.forEach(r => { r.eff = _effWeight(r.weight, r.updated_at); });
|
||||||
|
const top = rows.filter(r => r.eff >= 0.25).sort((a, b) => b.eff - a.eff).slice(0, 8);
|
||||||
|
const LBL = { difficulty: 'трудности', goal: 'цели', preference: 'предпочтения', strength: 'сильные стороны', personal: 'о себе', note: 'заметки' };
|
||||||
|
const byKind = {};
|
||||||
|
top.forEach(r => { (byKind[r.kind] || (byKind[r.kind] = [])).push(r.text); });
|
||||||
|
Object.keys(byKind).forEach(k => parts.push((LBL[k] || 'заметки') + ': ' + byKind[k].join('; ')));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return parts.join('; ');
|
return parts.join('; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert заметки с дедупликацией и лимитом.
|
// Стем-токены для сравнения заметок (русская морфология: «дробях»→«дроб»).
|
||||||
|
function _memTokens(text) {
|
||||||
|
const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3)) : w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w);
|
||||||
|
return Array.from(new Set(String(text || '').toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem)));
|
||||||
|
}
|
||||||
|
function _jaccard(a, b) {
|
||||||
|
if (!a.length || !b.length) return 0;
|
||||||
|
const sb = new Set(b); let inter = 0;
|
||||||
|
a.forEach(t => { if (sb.has(t)) inter++; });
|
||||||
|
return inter / (a.length + b.length - inter);
|
||||||
|
}
|
||||||
|
// Эффективный вес с затуханием по времени (полураспад ~31 день) — память остаётся свежей.
|
||||||
|
function _effWeight(weight, updatedAt) {
|
||||||
|
let days = 0;
|
||||||
|
try { days = (Date.now() - new Date(String(updatedAt).replace(' ', 'T') + 'Z').getTime()) / 86400000; } catch (e) {}
|
||||||
|
if (!(days > 0)) days = 0;
|
||||||
|
return weight * Math.exp(-days / 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert заметки: умное слияние похожих (а не накопление дублей) + лимит.
|
||||||
function _memUpsert(uid, kind, text, weight, source) {
|
function _memUpsert(uid, kind, text, weight, source) {
|
||||||
try {
|
try {
|
||||||
const key = text.toLowerCase().slice(0, 24);
|
text = String(text).trim().slice(0, 200);
|
||||||
const ex = db.prepare('SELECT id FROM assistant_memory WHERE user_id = ? AND lower(text) LIKE ?').get(uid, '%' + key + '%');
|
const toks = _memTokens(text);
|
||||||
if (ex) { db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, updated_at = datetime('now') WHERE id = ?").run(ex.id); return; }
|
if (!toks.length) return;
|
||||||
db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text.slice(0, 200), weight, source);
|
const rows = db.prepare('SELECT id, text FROM assistant_memory WHERE user_id = ?').all(uid);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (_jaccard(toks, _memTokens(r.text)) >= 0.5) { // та же мысль — слить, освежить, поднять вес
|
||||||
|
db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, text = ?, kind = ?, updated_at = datetime('now') WHERE id = ?").run(text, kind, r.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text, weight, source);
|
||||||
const cnt = db.prepare('SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?').get(uid).n;
|
const cnt = db.prepare('SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?').get(uid).n;
|
||||||
if (cnt > 15) db.prepare('DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)').run(uid, cnt - 15);
|
if (cnt > 18) db.prepare('DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)').run(uid, cnt - 18);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный).
|
// Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный).
|
||||||
|
const _MEM_CATS = new Set(['difficulty', 'preference', 'goal', 'strength', 'personal']);
|
||||||
|
// Доля кириллицы среди букв — гард, чтобы в память не попадал не-русский текст.
|
||||||
|
function _cyrShare(s) {
|
||||||
|
const letters = (String(s).match(/[a-zа-яё]/gi) || []).length;
|
||||||
|
const cyr = (String(s).match(/[а-яё]/gi) || []).length;
|
||||||
|
return letters ? cyr / letters : 0;
|
||||||
|
}
|
||||||
async function _extractMemory(uid, q, answer) {
|
async function _extractMemory(uid, q, answer) {
|
||||||
try {
|
try {
|
||||||
const sys = 'Ты ведёшь короткие заметки о трудностях, предпочтениях и целях ученика для персонализации обучения. ' +
|
const sys = 'Ты ведёшь короткие заметки об ученике для персонализации обучения. ' +
|
||||||
'По вопросу ученика и ответу выдели ОДИН устойчивый факт об ученике (что даётся трудно / что путает / предпочтение / цель). ' +
|
'По вопросу ученика и ответу выдели ОДИН устойчивый факт: что даётся ТРУДНО (difficulty), ПРЕДПОЧТЕНИЕ в обучении (preference), ЦЕЛЬ (goal), СИЛЬНАЯ сторона (strength) или ЛИЧНОЕ — класс/интересы (personal). ' +
|
||||||
'Ответь короткой фразой по-русски (до 12 слов), без кавычек. Если устойчивого факта нет — ответь ровно NONE.';
|
'Верни СТРОГО JSON {"cat":"difficulty|preference|goal|strength|personal","text":"<факт до 12 слов>"} ' +
|
||||||
const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 40);
|
'и текст ИСКЛЮЧИТЕЛЬНО НА РУССКОМ ЯЗЫКЕ, без кавычек внутри. Если устойчивого факта нет — верни {"cat":"none"}.';
|
||||||
const note = r && r.text && r.text.trim().replace(/^["'«»]+|["'«»]+$/g, '');
|
const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 60);
|
||||||
if (!note || /^none\b/i.test(note) || note.length < 5 || note.length > 120) return;
|
let cat = 'note', note = '';
|
||||||
_memUpsert(uid, 'note', note, 1, 'extractor');
|
const raw = r && r.text ? r.text.replace(/```(?:json)?/gi, '').trim() : '';
|
||||||
|
try {
|
||||||
|
const a = raw.indexOf('{'), b = raw.lastIndexOf('}');
|
||||||
|
const j = a >= 0 && b > a ? JSON.parse(raw.slice(a, b + 1)) : null;
|
||||||
|
if (j) { cat = String(j.cat || '').toLowerCase(); note = String(j.text || '').trim().replace(/^["'«»]+|["'«»]+$/g, ''); }
|
||||||
|
} catch (e) { /* не-JSON */ }
|
||||||
|
if (/^none\b/i.test(cat) || !note || note.length < 5 || note.length > 140) return;
|
||||||
|
if (_cyrShare(note) < 0.6) return; // не русский — не запоминаем
|
||||||
|
_memUpsert(uid, _MEM_CATS.has(cat) ? cat : 'note', note, 1, 'extractor');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +330,11 @@ function clearMemory(req, res) {
|
|||||||
const uid = req.user.id, id = req.params.id ? Number(req.params.id) : null;
|
const uid = req.user.id, id = req.params.id ? Number(req.params.id) : null;
|
||||||
try {
|
try {
|
||||||
if (id) db.prepare('DELETE FROM assistant_memory WHERE id = ? AND user_id = ?').run(id, uid);
|
if (id) db.prepare('DELETE FROM assistant_memory WHERE id = ? AND user_id = ?').run(id, uid);
|
||||||
else db.prepare('DELETE FROM assistant_memory WHERE user_id = ?').run(uid);
|
else {
|
||||||
|
db.prepare('DELETE FROM assistant_memory WHERE user_id = ?').run(uid);
|
||||||
|
// «Забыть всё»: сбрасываем и точку отсчёта производного профиля (слабые предметы/темы)
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, datetime('now'))").run('asst_forget_' + uid);
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -339,6 +418,8 @@ function searchFaq(q, n) {
|
|||||||
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
|
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
|
||||||
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
|
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
|
||||||
function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
|
function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
|
||||||
|
// Шлюзы с бесплатным инференсом БЕЗ ключа (наряду с localhost): ключ не обязателен.
|
||||||
|
function _noKeyNeeded(url) { return _isLocal(url) || /\/\/[^/]*\bpollinations\.ai\b/i.test(url || ''); }
|
||||||
|
|
||||||
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
|
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
|
||||||
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
|
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
|
||||||
@@ -357,7 +438,7 @@ function _providers() {
|
|||||||
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
|
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
|
||||||
* (для авто-перехвата при лимите/ошибке). */
|
* (для авто-перехвата при лимите/ошибке). */
|
||||||
function providersOrdered() {
|
function providersOrdered() {
|
||||||
const arr = _providers().filter(p => p && (p.key || _isLocal(p.url)));
|
const arr = _providers().filter(p => p && (p.key || _noKeyNeeded(p.url)));
|
||||||
const activeId = _setting('assistant_active');
|
const activeId = _setting('assistant_active');
|
||||||
const active = arr.filter(p => p.id === activeId);
|
const active = arr.filter(p => p.id === activeId);
|
||||||
const rest = arr.filter(p => p.id !== activeId);
|
const rest = arr.filter(p => p.id !== activeId);
|
||||||
@@ -394,6 +475,22 @@ function ragContext(q) {
|
|||||||
} catch (e) { return empty; }
|
} catch (e) { return empty; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Знания о системе (индексируются из админки): статус модулей + описание.
|
||||||
|
* Поиск по ключевым словам вопроса; добавляется в контекст ответа. */
|
||||||
|
function _systemKb() { try { const r = _setting('assistant_system_kb'); return r ? (JSON.parse(r) || []) : []; } catch (e) { return []; } }
|
||||||
|
function systemContext(q) {
|
||||||
|
const kb = _systemKb(); if (!kb.length) return '';
|
||||||
|
// стем-префикс (русская морфология): отбрасываем окончание, но не короче 4 симв.
|
||||||
|
// «флешкартами»→«флешкарт», «лабораторию»→«лаборато» ловят «флешкарты»/«лаборатория».
|
||||||
|
const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3))
|
||||||
|
: w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w);
|
||||||
|
const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem);
|
||||||
|
if (!words.length) return '';
|
||||||
|
const scored = kb.map(c => { const t = ((c.title || '') + ' ' + (c.text || '')).toLowerCase(); return { c, s: words.reduce((a, w) => a + (t.indexOf(w) >= 0 ? 1 : 0), 0) }; })
|
||||||
|
.filter(x => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 4);
|
||||||
|
return scored.map(x => x.c.text).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/* Суточный счётчик использования (для админки). */
|
/* Суточный счётчик использования (для админки). */
|
||||||
const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 };
|
const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 };
|
||||||
function bumpUsage(field) {
|
function bumpUsage(field) {
|
||||||
@@ -403,11 +500,11 @@ function bumpUsage(field) {
|
|||||||
|
|
||||||
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
||||||
/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
|
/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
|
||||||
async function callLLM(messages, maxTokens, override) {
|
async function callLLM(messages, maxTokens, override, timeoutMs) {
|
||||||
const cfg = override || llmConfig();
|
const cfg = override || llmConfig();
|
||||||
if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
|
if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs || 15000);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(cfg.url, {
|
const r = await fetch(cfg.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -433,12 +530,12 @@ function _recordFailover(failed, served, reason) {
|
|||||||
}
|
}
|
||||||
function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
|
function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
|
||||||
|
|
||||||
async function callLLMFailover(messages, maxTokens) {
|
async function callLLMFailover(messages, maxTokens, timeoutMs) {
|
||||||
const cfgs = providersOrdered();
|
const cfgs = providersOrdered();
|
||||||
if (!cfgs.length) return { text: null, error: 'off' };
|
if (!cfgs.length) return { text: null, error: 'off' };
|
||||||
let last = { text: null, error: 'off' }, firstErr = null;
|
let last = { text: null, error: 'off' }, firstErr = null;
|
||||||
for (let i = 0; i < cfgs.length; i++) {
|
for (let i = 0; i < cfgs.length; i++) {
|
||||||
last = await callLLM(messages, maxTokens, cfgs[i]);
|
last = await callLLM(messages, maxTokens, cfgs[i], timeoutMs);
|
||||||
if (i === 0) firstErr = last.error;
|
if (i === 0) firstErr = last.error;
|
||||||
if (last.text) {
|
if (last.text) {
|
||||||
if (i === 0) _clearFailover(); // активный работает — снимаем флаг
|
if (i === 0) _clearFailover(); // активный работает — снимаем флаг
|
||||||
@@ -451,11 +548,69 @@ async function callLLMFailover(messages, maxTokens) {
|
|||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Потоковый вызов OpenAI-совместимого chat/completions (stream:true).
|
||||||
|
* onDelta(piece) — на каждый кусок текста. Возвращает { text, any, error }. */
|
||||||
|
async function callLLMStream(messages, maxTokens, cfg, onDelta) {
|
||||||
|
if (typeof fetch !== 'function' || !cfg.on) return { text: null, any: false, error: 'off' };
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 60000); // стриминг длиннее обычного
|
||||||
|
try {
|
||||||
|
const r = await fetch(cfg.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
|
||||||
|
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 1200, messages, stream: true }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!r.ok) return { text: null, any: false, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status };
|
||||||
|
if (!r.body) return { text: null, any: false, error: 'empty' };
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '', full = '', any = false;
|
||||||
|
for await (const chunk of r.body) {
|
||||||
|
buf += dec.decode(chunk, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (data === '[DONE]') return { text: full || null, any, error: full ? null : 'empty' };
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(data);
|
||||||
|
const d = j.choices && j.choices[0] && j.choices[0].delta;
|
||||||
|
const piece = d && d.content;
|
||||||
|
if (piece) { full += piece; any = true; onDelta(piece); }
|
||||||
|
} catch (e) { /* частичный/служебный кусок — пропускаем */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: full || null, any, error: full ? null : 'empty' };
|
||||||
|
} catch (e) { return { text: null, any: false, error: e.name === 'AbortError' ? 'timeout' : 'network' }; }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стриминг с перебором провайдеров. Failover возможен ТОЛЬКО до первого куска;
|
||||||
|
* как только клиенту ушёл текст (any) — остаёмся на этом провайдере. */
|
||||||
|
async function callLLMStreamFailover(messages, maxTokens, onDelta) {
|
||||||
|
const cfgs = providersOrdered();
|
||||||
|
if (!cfgs.length) return { text: null, error: 'off' };
|
||||||
|
let firstErr = null;
|
||||||
|
for (let i = 0; i < cfgs.length; i++) {
|
||||||
|
const res = await callLLMStream(messages, maxTokens, cfgs[i], onDelta);
|
||||||
|
if (i === 0) firstErr = res.error;
|
||||||
|
if (res.text) {
|
||||||
|
if (i === 0) _clearFailover(); else _recordFailover(cfgs[0], cfgs[i], firstErr);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (res.any) return res; // часть уже улетела клиенту — переключиться нельзя
|
||||||
|
if (!_RETRYABLE[res.error]) break;
|
||||||
|
}
|
||||||
|
if (_RETRYABLE[firstErr]) _recordFailover(cfgs[0], null, firstErr);
|
||||||
|
return { text: null, error: firstErr || 'error' };
|
||||||
|
}
|
||||||
|
|
||||||
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
|
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
|
||||||
async function pingLLM(override) {
|
async function pingLLM(override) {
|
||||||
const cfg = override || llmConfig();
|
const cfg = override || llmConfig();
|
||||||
if (!cfg.url) return { ok: false, error: 'URL не задан' };
|
if (!cfg.url) return { ok: false, error: 'URL не задан' };
|
||||||
if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' };
|
if (!cfg.key && !_noKeyNeeded(cfg.url)) return { ok: false, error: 'Ключ не задан' };
|
||||||
if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
|
if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||||
@@ -496,7 +651,12 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа-яёa-z0-9,?!.-]{0,25}' + _TERM
|
|||||||
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i');
|
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i');
|
||||||
const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?';
|
const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?';
|
||||||
|
|
||||||
async function askModel(q, hits, context, history, role, mode, mem) {
|
// Анти-чит: явная просьба «сделай за меня» (а не «помоги разобраться»).
|
||||||
|
const _CHEAT_RE = /за\s+меня|вместо\s+меня|do\s+my\s+homework|(сделай|реши|выполни|напиши)\s+([а-яёА-ЯЁ]+\s+)?(дз|домашк|контрольн)/i;
|
||||||
|
function _socraticOn() { return _setting('assistant_socratic') === '1'; }
|
||||||
|
|
||||||
|
// Сборка messages+cap для модели — общая для обычного и стримингового ответа.
|
||||||
|
function buildAskMessages(q, hits, context, history, role, mode, mem, socratic) {
|
||||||
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
|
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
|
||||||
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
|
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
|
||||||
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
|
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
|
||||||
@@ -510,15 +670,33 @@ async function askModel(q, hits, context, history, role, mode, mem) {
|
|||||||
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
|
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
|
||||||
} else if (mode === 'check') {
|
} else if (mode === 'check') {
|
||||||
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
|
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
|
||||||
|
} else if (socratic) {
|
||||||
|
// Сократический режим (для учеников): теория — полно, но задачи не решаем «под ключ».
|
||||||
|
sys += ' СОКРАТИЧЕСКИЙ РЕЖИМ: понятия, определения и теорию объясняй полно и по существу. ' +
|
||||||
|
'Но если просят РЕШИТЬ конкретную задачу/пример/уравнение или «сделать» задание — НЕ выдавай готовое решение и итоговый ответ. ' +
|
||||||
|
'Вместо этого назови нужный метод/формулу, разбери первый шаг и задай наводящий вопрос, предложи ученику продолжить самому. ' +
|
||||||
|
'Если ученик пришлёт свой шаг или ответ — проверь и мягко направь дальше. Будь доброжелателен, подбадривай.';
|
||||||
}
|
}
|
||||||
const msgs = [{ role: 'system', content: sys }];
|
const msgs = [{ role: 'system', content: sys }];
|
||||||
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
|
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
|
||||||
msgs.push({ role: 'user', content: user });
|
msgs.push({ role: 'user', content: user });
|
||||||
// подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине
|
// подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине
|
||||||
const cap = mode === 'hint' ? 320 : (mode === 'check' ? 900 : 1200);
|
const cap = mode === 'hint' ? 320 : (mode === 'check' ? 900 : 1200);
|
||||||
|
return { msgs, cap };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askModel(q, hits, context, history, role, mode, mem, socratic) {
|
||||||
|
const { msgs, cap } = buildAskMessages(q, hits, context, history, role, mode, mem, socratic);
|
||||||
return callLLMFailover(msgs, cap);
|
return callLLMFailover(msgs, cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сократический режим включается для УЧЕНИКА: если включён тумблер ИЛИ явная просьба «сделай за меня».
|
||||||
|
function _socraticFor(role, mode, q) {
|
||||||
|
if (role && role !== 'student') return false; // учителям/админам не ограничиваем
|
||||||
|
if (mode !== 'answer') return false; // hint/check уже наводящие
|
||||||
|
return _socraticOn() || _CHEAT_RE.test(q || '');
|
||||||
|
}
|
||||||
|
|
||||||
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
|
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
|
||||||
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
|
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
|
||||||
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
|
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
|
||||||
@@ -529,7 +707,7 @@ async function ask(req, res) {
|
|||||||
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
|
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
|
||||||
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
|
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
|
||||||
let history = (req.body && req.body.history);
|
let history = (req.body && req.body.history);
|
||||||
history = Array.isArray(history) ? history.slice(-6) : [];
|
history = Array.isArray(history) ? history.slice(-14) : [];
|
||||||
const hits = searchFaq(q, 3);
|
const hits = searchFaq(q, 3);
|
||||||
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
|
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
|
||||||
|
|
||||||
@@ -550,9 +728,12 @@ async function ask(req, res) {
|
|||||||
|
|
||||||
let context = pageCtx;
|
let context = pageCtx;
|
||||||
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
||||||
|
const sysCtx = systemContext(q);
|
||||||
|
if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx;
|
||||||
|
|
||||||
|
const socratic = _socraticFor(req.user && req.user.role, mode, q);
|
||||||
let r = { text: null, error: 'network' };
|
let r = { text: null, error: 'network' };
|
||||||
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem); } catch (e) { r = { text: null, error: 'network' }; }
|
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); } catch (e) { r = { text: null, error: 'network' }; }
|
||||||
const answer = r && r.text;
|
const answer = r && r.text;
|
||||||
|
|
||||||
if (answer) {
|
if (answer) {
|
||||||
@@ -572,6 +753,68 @@ async function ask(req, res) {
|
|||||||
res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
|
res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/assistant/ask/stream ── то же, что ask, но ответ модели стримится
|
||||||
|
* по SSE (event: meta|delta|done). Быстрые пути (FAQ/кэш/мета) отдаются одним done. */
|
||||||
|
async function askStream(req, res) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no'); // не буферизовать за прокси
|
||||||
|
if (res.flushHeaders) res.flushHeaders();
|
||||||
|
const sse = (event, data) => { try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) {} };
|
||||||
|
|
||||||
|
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
|
||||||
|
if (!q || q.length < 2) { sse('done', { source: 'faq', answer: null, answers: [] }); return res.end(); }
|
||||||
|
if (META_RE.test(q)) { sse('delta', { t: META_ANSWER }); sse('done', { source: 'model', answers: [], sources: [] }); return res.end(); }
|
||||||
|
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
|
||||||
|
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
|
||||||
|
let history = (req.body && req.body.history);
|
||||||
|
history = Array.isArray(history) ? history.slice(-14) : [];
|
||||||
|
const hits = searchFaq(q, 3);
|
||||||
|
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
|
||||||
|
sse('meta', { answers: faqJson });
|
||||||
|
|
||||||
|
if (!providersOrdered().length) { bumpUsage('faq'); sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] }); return res.end(); }
|
||||||
|
|
||||||
|
const rag = ragContext(q);
|
||||||
|
const mem = _memoryBlock(req.user.id);
|
||||||
|
const cacheable = mode === 'answer' && !pageCtx && !history.length && !mem;
|
||||||
|
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
if (cacheable) {
|
||||||
|
try {
|
||||||
|
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash);
|
||||||
|
if (c) { bumpUsage('cache_hits'); sse('delta', { t: c.answer }); sse('done', { source: 'model', answers: faqJson, sources: rag.sources, cached: true }); return res.end(); }
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (rag.sources && rag.sources.length) sse('meta', { sources: rag.sources });
|
||||||
|
|
||||||
|
let context = pageCtx;
|
||||||
|
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
||||||
|
const sysCtx = systemContext(q);
|
||||||
|
if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx;
|
||||||
|
const socratic = _socraticFor(req.user && req.user.role, mode, q);
|
||||||
|
const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem, socratic);
|
||||||
|
|
||||||
|
let full = '';
|
||||||
|
let r = { text: null, error: 'network' };
|
||||||
|
try { r = await callLLMStreamFailover(msgs, cap, (piece) => { full += piece; sse('delta', { t: piece }); }); }
|
||||||
|
catch (e) { r = { text: null, error: 'network' }; }
|
||||||
|
|
||||||
|
const answer = (r && r.text) || full;
|
||||||
|
if (answer) {
|
||||||
|
bumpUsage('model_calls');
|
||||||
|
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
|
||||||
|
if (_setting('assistant_memory') !== '0' && (mode === 'check' || history.length >= 4)) _extractMemory(req.user.id, q, answer);
|
||||||
|
sse('done', { source: 'model', answers: faqJson, sources: rag.sources });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
bumpUsage('faq');
|
||||||
|
if (r && r.error === 'rate_limit') sse('done', { source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] });
|
||||||
|
else if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) sse('done', { source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] });
|
||||||
|
else sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
|
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
|
||||||
function feedback(req, res) {
|
function feedback(req, res) {
|
||||||
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
|
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
|
||||||
@@ -596,7 +839,7 @@ async function flashcardsFromText(req, res) {
|
|||||||
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида {"front":"...","back":"..."} без markdown и пояснений. ' +
|
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида {"front":"...","back":"..."} без markdown и пояснений. ' +
|
||||||
'front — короткий вопрос, back — краткий ответ (1–2 предложения). По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
|
'front — короткий вопрос, back — краткий ответ (1–2 предложения). По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
|
||||||
let rr;
|
let rr;
|
||||||
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600); }
|
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600, 40000); }
|
||||||
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
|
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
|
||||||
const raw = rr && rr.text;
|
const raw = rr && rr.text;
|
||||||
let cards = [];
|
let cards = [];
|
||||||
@@ -621,4 +864,50 @@ async function flashcardsFromText(req, res) {
|
|||||||
res.json({ title, cards });
|
res.json({ title, cards });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
|
/* ── POST /api/assistant/questions { text, count? } ── учитель: сгенерировать
|
||||||
|
* тестовые вопросы (single-choice) из темы/текста для банка вопросов. */
|
||||||
|
async function questionsFromText(req, res) {
|
||||||
|
if (!providersOrdered().length) return res.status(503).json({ error: 'LLM не настроена' });
|
||||||
|
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
|
||||||
|
let count = Number(req.body && req.body.count);
|
||||||
|
count = Number.isFinite(count) ? Math.max(3, Math.min(10, Math.round(count))) : 5;
|
||||||
|
if (text.length < 3) return res.status(400).json({ error: 'Введите тему или текст' });
|
||||||
|
const sys = 'Ты составляешь тестовые вопросы с выбором одного верного ответа для школьников. ' +
|
||||||
|
'Если дан учебный текст/параграф — делай вопросы СТРОГО по нему; если дана короткая тема — раскрой её по школьной программе. ' +
|
||||||
|
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида ' +
|
||||||
|
'{"q":"текст вопроса","options":["вариант1","вариант2","вариант3","вариант4"],"correct":0,"explanation":"кратко, почему верен"}. ' +
|
||||||
|
'РОВНО 4 варианта; correct — индекс правильного (0..3); ровно один правильный. ' +
|
||||||
|
'По-русски, формулы в LaTeX между $...$. Никакого текста вне JSON, без markdown.';
|
||||||
|
let rr;
|
||||||
|
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200, 45000); }
|
||||||
|
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
|
||||||
|
const raw = rr && rr.text;
|
||||||
|
let questions = [];
|
||||||
|
if (raw) {
|
||||||
|
let s = raw.replace(/```(?:json)?/gi, '').trim();
|
||||||
|
const a = s.indexOf('[');
|
||||||
|
if (a >= 0) {
|
||||||
|
const b = s.lastIndexOf(']');
|
||||||
|
if (b > a) s = s.slice(a, b + 1);
|
||||||
|
else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(s);
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
questions = arr
|
||||||
|
.filter(x => x && x.q && Array.isArray(x.options) && x.options.length >= 2)
|
||||||
|
.slice(0, count + 2)
|
||||||
|
.map(x => {
|
||||||
|
const opts = x.options.slice(0, 6).map(o => String(o).slice(0, 300)).filter(Boolean);
|
||||||
|
let correct = Number(x.correct); if (!Number.isInteger(correct) || correct < 0 || correct >= opts.length) correct = 0;
|
||||||
|
return { q: String(x.q).slice(0, 1000), options: opts, correct, explanation: String(x.explanation || '').slice(0, 600) };
|
||||||
|
})
|
||||||
|
.filter(x => x.options.length >= 2);
|
||||||
|
}
|
||||||
|
} catch (e) { /* не-JSON */ }
|
||||||
|
}
|
||||||
|
if (!questions.length) return res.status(502).json({ error: 'Не удалось сгенерировать вопросы' });
|
||||||
|
res.json({ questions });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getContext, markSeen, dismiss, setSettings, ask, askStream, flashcardsFromText, questionsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { audit } = require('../utils/audit');
|
const { audit } = require('../utils/audit');
|
||||||
|
const { checkMagicBytes } = require('../utils/magic');
|
||||||
|
|
||||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
|||||||
function requestAvatar(req, res) {
|
function requestAvatar(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||||
|
|
||||||
|
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
|
||||||
|
const filePath = path.join(AVATARS_DIR, req.file.filename);
|
||||||
|
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
|
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel any previous pending request from this user (replace it)
|
// Cancel any previous pending request from this user (replace it)
|
||||||
const prev = db.prepare(
|
const prev = db.prepare(
|
||||||
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
|
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { emitToUser } = require('../../ws-server');
|
const { emitToUser } = require('../../ws-server');
|
||||||
const { emitToSession, hasAccess } = require('./_shared');
|
const { emitToSession, hasAccess } = require('./_shared');
|
||||||
|
const { checkMagicBytes } = require('../../utils/magic');
|
||||||
|
|
||||||
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
|
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
|
||||||
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
|
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
|
||||||
@@ -118,6 +119,12 @@ function reactToMessage(req, res) {
|
|||||||
|
|
||||||
function uploadChatAttachment(req, res) {
|
function uploadChatAttachment(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
||||||
|
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
|
||||||
|
const filePath = path.join(CHAT_UPLOADS_DIR, req.file.filename);
|
||||||
|
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
|
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
|
||||||
|
}
|
||||||
const url = `/uploads/chat/${req.file.filename}`;
|
const url = `/uploads/chat/${req.file.filename}`;
|
||||||
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
||||||
res.json({ url, type, name: req.file.originalname });
|
res.json({ url, type, name: req.file.originalname });
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ function createSession(req, res) {
|
|||||||
|
|
||||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
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 });
|
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);
|
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_draw_permissions WHERE session_id=?').run(sessionId);
|
||||||
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
||||||
emitToSession(sessionId, { type: 'classroom_ended', 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 });
|
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 };
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Пользовательские генераторы тренажёра (конструктор, P13).
|
||||||
|
*
|
||||||
|
* Спек генератора — ДАННЫЕ; на клиенте его исполняет безопасный SimExpr (⛔ без
|
||||||
|
* eval). Сервер НЕ исполняет — только валидирует структуру/лимиты и хранит.
|
||||||
|
* Текст НЕ экранируется на сервере: клиент рендерит безопасно (textContent / esc),
|
||||||
|
* а выражения проходят через SimExpr. Стиль — customSimController/studentMaterials:
|
||||||
|
* read auth-only (own + published), мутации — requireRole + per-row ownership.
|
||||||
|
*/
|
||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
const KINDS = { solve: 1, compute: 1, roots: 1, simplify: 1, inequality: 1 };
|
||||||
|
const MAX_SPEC = 20000;
|
||||||
|
|
||||||
|
function clip(v, n) { return (typeof v === 'string') ? (v.length > n ? v.slice(0, n) : v) : ''; }
|
||||||
|
function expr(v, n) { return (typeof v === 'string') ? clip(v.trim(), n || 200) : ''; }
|
||||||
|
const NAME = /^[a-zA-Z][a-zA-Z0-9]{0,12}$/;
|
||||||
|
|
||||||
|
/* Валидация спека БЕЗ исполнения: типы/лимиты. Возврат { ok, clean?, error? }. */
|
||||||
|
function validateGenSpec(spec) {
|
||||||
|
if (!spec || typeof spec !== 'object') return { ok: false, error: 'спек отсутствует' };
|
||||||
|
if (JSON.stringify(spec).length > MAX_SPEC) return { ok: false, error: 'спек слишком большой' };
|
||||||
|
|
||||||
|
const title = clip(String(spec.title || '').trim(), 120);
|
||||||
|
if (!title) return { ok: false, error: 'нужен заголовок' };
|
||||||
|
const topic = clip(String(spec.topic || 'custom').trim(), 60) || 'custom';
|
||||||
|
const kind = (typeof spec.kind === 'string' && KINDS[spec.kind]) ? spec.kind : 'solve';
|
||||||
|
|
||||||
|
// pick: имя → [min,max] целые
|
||||||
|
const pick = {};
|
||||||
|
if (spec.pick && typeof spec.pick === 'object') {
|
||||||
|
for (const k of Object.keys(spec.pick).slice(0, 20)) {
|
||||||
|
const r = spec.pick[k];
|
||||||
|
if (NAME.test(k) && Array.isArray(r) && r.length === 2 && Number.isInteger(r[0]) && Number.isInteger(r[1])) {
|
||||||
|
pick[k] = [r[0], r[1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// derive: имя → формула (строка)
|
||||||
|
const derive = {};
|
||||||
|
if (spec.derive && typeof spec.derive === 'object') {
|
||||||
|
for (const k of Object.keys(spec.derive).slice(0, 30)) {
|
||||||
|
if (NAME.test(k) && typeof spec.derive[k] === 'string') derive[k] = expr(spec.derive[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// solution: [{ note, tex }]
|
||||||
|
let solution = [];
|
||||||
|
if (Array.isArray(spec.solution)) {
|
||||||
|
solution = spec.solution.slice(0, 12).map(st => ({
|
||||||
|
note: clip(String((st && st.note) || ''), 300),
|
||||||
|
tex: expr(st && st.tex)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// answers: массив выражений (kind roots)
|
||||||
|
let answers;
|
||||||
|
if (Array.isArray(spec.answers)) answers = spec.answers.slice(0, 6).map(a => expr(a)).filter(Boolean);
|
||||||
|
|
||||||
|
const clean = {
|
||||||
|
title, topic, kind,
|
||||||
|
pick,
|
||||||
|
derive: Object.keys(derive).length ? derive : undefined,
|
||||||
|
constraint: expr(spec.constraint) || undefined,
|
||||||
|
require: expr(spec.require) || undefined,
|
||||||
|
lhs: expr(spec.lhs) || 'x',
|
||||||
|
rhs: expr(spec.rhs) || 'x',
|
||||||
|
display: (typeof spec.display === 'string' && spec.display.trim()) ? clip(spec.display, 200) : undefined,
|
||||||
|
srcExpr: expr(spec.srcExpr) || undefined,
|
||||||
|
answerExpr: expr(spec.answerExpr) || undefined,
|
||||||
|
dispOp: ['<', '>', '<=', '>='].indexOf(spec.dispOp) !== -1 ? spec.dispOp : undefined,
|
||||||
|
relOp: ['<', '>', '<=', '>='].indexOf(spec.relOp) !== -1 ? spec.relOp : undefined,
|
||||||
|
bound: expr(spec.bound) || undefined,
|
||||||
|
answer: expr(spec.answer) || undefined,
|
||||||
|
answers: (answers && answers.length) ? answers : undefined,
|
||||||
|
answerVar: /^[a-z]$/.test(spec.answerVar) ? spec.answerVar : 'x',
|
||||||
|
integerAnswer: !!spec.integerAnswer,
|
||||||
|
solution
|
||||||
|
};
|
||||||
|
Object.keys(clean).forEach(k => clean[k] === undefined && delete clean[k]);
|
||||||
|
return { ok: true, clean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Строка БД → объект-генератор для клиента (готов к TE.instantiate). */
|
||||||
|
function toClientGen(row) {
|
||||||
|
let spec = {};
|
||||||
|
try { spec = JSON.parse(row.spec_json) || {}; } catch (e) { spec = {}; }
|
||||||
|
spec.id = 'cg' + row.id; // ключ навыка/прогресса
|
||||||
|
spec.title = row.title;
|
||||||
|
spec.topic = row.topic || 'custom';
|
||||||
|
spec.dbid = row.id;
|
||||||
|
spec.owner_id = row.owner_id;
|
||||||
|
spec.status = row.status;
|
||||||
|
spec._custom = true;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/practice/generators — свои + опубликованные. */
|
||||||
|
function genList(req, res) {
|
||||||
|
const uid = req.user.id;
|
||||||
|
const rows = db.prepare(
|
||||||
|
"SELECT * FROM custom_generators WHERE owner_id = ? OR status = 'published' ORDER BY updated_at DESC, id DESC"
|
||||||
|
).all(uid);
|
||||||
|
res.json({ generators: rows.map(toClientGen) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/practice/generators/:id — свой или опубликованный. */
|
||||||
|
// @public-by-design: auth-only; видимость own+published проверяется в хендлере.
|
||||||
|
function genGet(req, res) {
|
||||||
|
const uid = req.user.id, role = req.user.role;
|
||||||
|
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||||
|
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||||
|
if (row.owner_id !== uid && row.status !== 'published' && role !== 'admin') return res.status(403).json({ error: 'нет доступа' });
|
||||||
|
res.json({ generator: toClientGen(row) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function genCreate(req, res) {
|
||||||
|
const v = validateGenSpec(req.body && req.body.spec);
|
||||||
|
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||||
|
const status = (req.body && req.body.status === 'published') ? 'published' : 'draft';
|
||||||
|
const info = db.prepare(
|
||||||
|
'INSERT INTO custom_generators (owner_id, title, topic, spec_json, status) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(req.user.id, v.clean.title, v.clean.topic, JSON.stringify(v.clean), status);
|
||||||
|
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(info.lastInsertRowid);
|
||||||
|
res.json({ ok: true, generator: toClientGen(row) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function genUpdate(req, res) {
|
||||||
|
const uid = req.user.id, role = req.user.role;
|
||||||
|
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||||
|
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||||
|
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||||
|
|
||||||
|
let title = row.title, topic = row.topic, specJson = row.spec_json;
|
||||||
|
if (req.body && req.body.spec) {
|
||||||
|
const v = validateGenSpec(req.body.spec);
|
||||||
|
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||||
|
title = v.clean.title; topic = v.clean.topic; specJson = JSON.stringify(v.clean);
|
||||||
|
}
|
||||||
|
const status = (req.body && (req.body.status === 'published' || req.body.status === 'draft')) ? req.body.status : row.status;
|
||||||
|
db.prepare("UPDATE custom_generators SET title = ?, topic = ?, spec_json = ?, status = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(title, topic, specJson, status, row.id);
|
||||||
|
const upd = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(row.id);
|
||||||
|
res.json({ ok: true, generator: toClientGen(upd) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function genDelete(req, res) {
|
||||||
|
const uid = req.user.id, role = req.user.role;
|
||||||
|
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||||
|
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||||
|
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||||
|
db.prepare('DELETE FROM custom_generators WHERE id = ?').run(row.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateGenSpec, genList, genGet, genCreate, genUpdate, genDelete };
|
||||||
@@ -298,6 +298,9 @@ function unassignFile(req, res) {
|
|||||||
function getFolderAccess(req, res) {
|
function getFolderAccess(req, res) {
|
||||||
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
|
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
|
||||||
if (!fo) return res.status(404).json({ error: 'Folder not found' });
|
if (!fo) return res.status(404).json({ error: 'Folder not found' });
|
||||||
|
// Список раздачи (с именами/email учеников) — только владельцу папки или админу.
|
||||||
|
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT fa.id, fa.type, fa.target_id,
|
SELECT fa.id, fa.type, fa.target_id,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
|
const { checkMagicBytes } = require('../utils/magic');
|
||||||
const prepTracks = require('../services/prepTracks');
|
const prepTracks = require('../services/prepTracks');
|
||||||
|
|
||||||
|
const _fcUploadsDir = path.join(__dirname, '../../uploads/flashcards');
|
||||||
|
|
||||||
/* ── валидация URL картинки ────────────────────────────────────────────────
|
/* ── валидация URL картинки ────────────────────────────────────────────────
|
||||||
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
||||||
защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */
|
защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */
|
||||||
@@ -498,6 +503,12 @@ function getRandom(req, res) {
|
|||||||
back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */
|
back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */
|
||||||
function uploadImage(req, res) {
|
function uploadImage(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' });
|
if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' });
|
||||||
|
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
|
||||||
|
const filePath = path.join(_fcUploadsDir, req.file.filename);
|
||||||
|
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
|
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
|
||||||
|
}
|
||||||
res.json({ url: `/uploads/flashcards/${req.file.filename}` });
|
res.json({ url: `/uploads/flashcards/${req.file.filename}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onLabExperiment(userId, reactionsDiscovered) {
|
function onLabExperiment(userId, reactionsDiscovered) {
|
||||||
|
if (!isGamificationEnabled()) return; // master kill-switch
|
||||||
stmts.incrLabExp.run(userId);
|
stmts.incrLabExp.run(userId);
|
||||||
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
|
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
|
||||||
awardXP(userId, 15, 'lab_experiment');
|
awardXP(userId, 15, 'lab_experiment');
|
||||||
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateChallenges(userId, score, total, subjectSlug, topicId) {
|
function updateChallenges(userId, score, total, subjectSlug, topicId) {
|
||||||
|
if (!isGamificationEnabled()) return; // master kill-switch
|
||||||
const week = _currentWeek();
|
const week = _currentWeek();
|
||||||
const pct = total > 0 ? Math.round(score / total * 100) : 0;
|
const pct = total > 0 ? Math.round(score / total * 100) : 0;
|
||||||
const challenges = stmts.getOpenChallenges.all(userId, week);
|
const challenges = stmts.getOpenChallenges.all(userId, week);
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Practice progress (ИИ-тренажёр, Фаза 0).
|
||||||
|
*
|
||||||
|
* Прогресс ученика по навыкам тренажёра. Навык = skill генератора; задачи
|
||||||
|
* генерируются и проверяются на клиенте (детерминированно, подстановкой), а
|
||||||
|
* сервер хранит только агрегаты. На каждую попытку клиент шлёт { skill, correct };
|
||||||
|
* сервер делает upsert: solved/attempts, текущая и лучшая серия, флаг mastered.
|
||||||
|
*
|
||||||
|
* Стиль следует gameController / customSimController: node:sqlite db.prepare,
|
||||||
|
* auth-only (роутер ставит authMiddleware), валидация входа без исполнения,
|
||||||
|
* статусы 400. Прогресс всегда принадлежит req.user — проверка владения не нужна.
|
||||||
|
*/
|
||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
const MAX_SKILL = 120; // длина skill (TEXT)
|
||||||
|
const MASTERY_STREAK = 5; // серия верных подряд для «освоено»
|
||||||
|
// Интервалы повторения (дни) по уровню Leitner-коробки box 0..5.
|
||||||
|
const INTERVAL_DAYS = [0, 1, 3, 7, 16, 30];
|
||||||
|
|
||||||
|
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам.
|
||||||
|
* `due` (0/1) — навык пора повторить (срок прошёл или не назначен). */
|
||||||
|
function listProgress(req, res) {
|
||||||
|
const uid = req.user.id;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
|
||||||
|
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
|
||||||
|
updated_at
|
||||||
|
FROM practice_progress
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
`).all(uid);
|
||||||
|
res.json({ progress: rows, masteryStreak: MASTERY_STREAK });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/practice/attempt body: { skill, correct }
|
||||||
|
* Upsert агрегата попытки. Валидация: skill строка ≤120; correct — boolean.
|
||||||
|
* НИЧЕГО не исполняет (skill — лишь ключ). */
|
||||||
|
function submitAttempt(req, res) {
|
||||||
|
const uid = req.user.id;
|
||||||
|
const b = req.body || {};
|
||||||
|
|
||||||
|
const skill = typeof b.skill === 'string' ? b.skill.trim() : '';
|
||||||
|
if (!skill) return res.status(400).json({ error: 'skill обязателен' });
|
||||||
|
if (skill.length > MAX_SKILL) return res.status(400).json({ error: `skill длиннее ${MAX_SKILL} символов` });
|
||||||
|
if (typeof b.correct !== 'boolean') return res.status(400).json({ error: 'correct должно быть boolean' });
|
||||||
|
|
||||||
|
const correct = b.correct;
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id, solved, attempts, cur_streak, best_streak, mastered, box FROM practice_progress WHERE user_id = ? AND skill = ?'
|
||||||
|
).get(uid, skill);
|
||||||
|
|
||||||
|
// Leitner: верно → box+1 (до 5), неверно → 0. Срок = сейчас + интервал(box).
|
||||||
|
const prevBox = existing ? (existing.box || 0) : 0;
|
||||||
|
const box = correct ? Math.min(prevBox + 1, 5) : 0;
|
||||||
|
const dueMod = '+' + INTERVAL_DAYS[box] + ' days';
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const curStreak = correct ? 1 : 0;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now', ?), datetime('now'))
|
||||||
|
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0, box, dueMod);
|
||||||
|
} else {
|
||||||
|
const curStreak = correct ? (existing.cur_streak + 1) : 0;
|
||||||
|
const bestStreak = Math.max(existing.best_streak || 0, curStreak);
|
||||||
|
const mastered = (existing.mastered || (curStreak >= MASTERY_STREAK)) ? 1 : 0;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE practice_progress
|
||||||
|
SET solved = solved + ?, attempts = attempts + 1,
|
||||||
|
cur_streak = ?, best_streak = ?, mastered = ?, box = ?, due_at = datetime('now', ?),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, box, dueMod, existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
|
||||||
|
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
|
||||||
|
updated_at
|
||||||
|
FROM practice_progress WHERE user_id = ? AND skill = ?
|
||||||
|
`).get(uid, skill);
|
||||||
|
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
|
||||||
|
const genService = require('../services/practiceGenService');
|
||||||
|
const explainService = require('../services/practiceExplainService');
|
||||||
|
const { pushNotif } = require('../utils/notifications');
|
||||||
|
const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
|
||||||
|
|
||||||
|
function toClientProblem(r) {
|
||||||
|
let solution = [];
|
||||||
|
try { solution = r.solution_json ? JSON.parse(r.solution_json) : []; } catch (e) { solution = []; }
|
||||||
|
return {
|
||||||
|
id: r.id, kind: 'word', topic: r.topic, skill: r.skill,
|
||||||
|
story: r.story, lhsExpr: r.lhs, rhsExpr: r.rhs,
|
||||||
|
answerVar: r.answer_var, answer: r.answer, solution: solution
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/practice/pool?skill=&limit= — одобренные задачи пула (ученикам). */
|
||||||
|
function listPool(req, res) {
|
||||||
|
const skill = (req.query && typeof req.query.skill === 'string') ? req.query.skill.trim().slice(0, MAX_SKILL) : '';
|
||||||
|
const limit = Math.min(parseInt((req.query && req.query.limit), 10) || 20, 50);
|
||||||
|
const rows = skill
|
||||||
|
? db.prepare("SELECT * FROM practice_problems WHERE status='approved' AND (skill = ? OR topic = ?) ORDER BY id DESC LIMIT ?").all(skill, skill, limit)
|
||||||
|
: db.prepare("SELECT * FROM practice_problems WHERE status='approved' ORDER BY id DESC LIMIT ?").all(limit);
|
||||||
|
res.json({ problems: rows.map(toClientProblem) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/practice/generate { topic } — учитель/админ генерирует задачу в пул.
|
||||||
|
* Сервис проверяет корректность подстановкой; не прошло — в БД НЕ пишем. */
|
||||||
|
async function generateProblem(req, res) {
|
||||||
|
const topic = (req.body && typeof req.body.topic === 'string') ? req.body.topic.trim() : 'word-linear';
|
||||||
|
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try { result = await genService.generate(topic, { maxRetries: 3 }); }
|
||||||
|
catch (e) { return res.status(500).json({ error: 'generation failed' }); }
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = (result.error === 'off') ? 503 : 422; // нет провайдера → 503; не проверилось → 422
|
||||||
|
return res.status(code).json({ error: result.error, reason: result.reason || null, attempts: result.attempts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = result.problem;
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?)
|
||||||
|
`).run(topic, topic, 1, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
|
||||||
|
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/practice/author — учитель пишет задачу ВРУЧНУЮ (без LLM).
|
||||||
|
* Та же проверка подстановкой (validateAndVerify): не сходится → 422, в пул не пишем. */
|
||||||
|
function authorProblem(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const topic = (typeof b.topic === 'string') ? b.topic.trim() : 'word-linear';
|
||||||
|
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
|
||||||
|
|
||||||
|
const v = genService.validateAndVerify({
|
||||||
|
story: b.story, lhs: b.lhs, rhs: b.rhs, answer: b.answer, answerVar: b.answerVar, solution: b.solution
|
||||||
|
});
|
||||||
|
if (!v.ok) return res.status(422).json({ error: 'verify', reason: v.reason });
|
||||||
|
|
||||||
|
const p = v.problem;
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'approved', ?)
|
||||||
|
`).run(topic, topic, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
|
||||||
|
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
|
||||||
|
res.json({ ok: true, problem: toClientProblem(row) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/practice/assign { class_id, topic, title } — выдать тему классу.
|
||||||
|
* Адресное durable-уведомление каждому ученику (pushNotif → таблица + SSE), ссылка /trainer.
|
||||||
|
* Доступ: владелец класса или админ. */
|
||||||
|
function assignToClass(req, res) {
|
||||||
|
const uid = req.user.id, role = req.user.role;
|
||||||
|
const b = req.body || {};
|
||||||
|
const classId = parseInt(b.class_id, 10);
|
||||||
|
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
||||||
|
const title = (typeof b.title === 'string' ? b.title.trim() : '').slice(0, 200);
|
||||||
|
|
||||||
|
if (role !== 'admin') {
|
||||||
|
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
|
||||||
|
if (!own) return res.status(403).json({ error: 'не ваш класс' });
|
||||||
|
}
|
||||||
|
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId);
|
||||||
|
const msg = 'Тренажёр: ' + (title || 'новое задание для практики');
|
||||||
|
members.forEach(m => pushNotif(m.user_id, 'practice', msg, '/trainer'));
|
||||||
|
res.json({ ok: true, notified: members.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
|
||||||
|
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
|
||||||
|
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
|
||||||
|
function classStats(req, res) {
|
||||||
|
const uid = req.user.id, role = req.user.role;
|
||||||
|
const classId = parseInt((req.query && req.query.class_id), 10);
|
||||||
|
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
||||||
|
|
||||||
|
if (role !== 'admin') {
|
||||||
|
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
|
||||||
|
if (!own) return res.status(403).json({ error: 'не ваш класс' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const students = db.prepare(
|
||||||
|
'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name'
|
||||||
|
).all(classId);
|
||||||
|
if (!students.length) return res.json({ students: [], skills: [], perSkill: [] });
|
||||||
|
|
||||||
|
const ids = students.map(s => s.id);
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})`
|
||||||
|
).all(...ids);
|
||||||
|
|
||||||
|
const bySkill = {}, byStudent = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 });
|
||||||
|
s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++;
|
||||||
|
const st = byStudent[r.user_id] || (byStudent[r.user_id] = {});
|
||||||
|
st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0,
|
||||||
|
accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 };
|
||||||
|
}
|
||||||
|
const skills = Object.keys(bySkill).sort();
|
||||||
|
const perSkill = skills.map(k => {
|
||||||
|
const s = bySkill[k];
|
||||||
|
return { skill: k, attempted: s.attempted, mastered: s.mastered,
|
||||||
|
accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 };
|
||||||
|
});
|
||||||
|
const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} }));
|
||||||
|
res.json({ students: studentRows, skills, perSkill });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/practice/explain { display, answer, steps, studentAnswer, mode } — ИИ-репетитор.
|
||||||
|
* Объясняет ошибку (mode 'mistake') или даёт наводящую подсказку ('hint'), ОПИРАЯСЬ на уже
|
||||||
|
* известный правильный ответ и шаги (grounding — модель не считает). Доступ: любой
|
||||||
|
* авторизованный (тренируются ученики). Нет/выключен LLM → 503; клиент мягко падает на
|
||||||
|
* детерминированное решение. */
|
||||||
|
async function explainProblem(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const mode = (b.mode === 'hint') ? 'hint' : 'mistake';
|
||||||
|
const display = (typeof b.display === 'string') ? b.display : '';
|
||||||
|
if (!display.trim()) return res.status(400).json({ error: 'no problem' });
|
||||||
|
const answer = (typeof b.answer === 'string') ? b.answer : String(b.answer == null ? '' : b.answer);
|
||||||
|
const steps = Array.isArray(b.steps)
|
||||||
|
? b.steps.slice(0, 8).map(s => ({ note: (s && s.note) || '', tex: (s && s.tex) || '' }))
|
||||||
|
: [];
|
||||||
|
const studentAnswer = (typeof b.studentAnswer === 'string') ? b.studentAnswer : String(b.studentAnswer == null ? '' : b.studentAnswer);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try { result = await explainService.explain({ problem: { display, answer, solution: steps }, studentAnswer, mode }); }
|
||||||
|
catch (e) { return res.status(500).json({ error: 'explain failed' }); }
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = (result.error === 'off' || result.error === 'ask-threw') ? 503 : 422;
|
||||||
|
return res.status(code).json({ error: result.error });
|
||||||
|
}
|
||||||
|
res.json({ ok: true, text: result.text, mode: result.mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats, explainProblem };
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
/* Вопросы теста без зафиксированного правильного ответа (нет верного варианта И
|
||||||
|
* нет correct_text). matching исключаем (там ответ — пары match_pair).
|
||||||
|
* Такой вопрос нельзя оценить → не пускаем тест к ученикам. */
|
||||||
|
function unanswerableInTest(testId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT tq.question_id AS id FROM test_questions tq JOIN questions q ON q.id = tq.question_id
|
||||||
|
WHERE tq.test_id = ? AND q.type <> 'matching'
|
||||||
|
AND (q.correct_text IS NULL OR TRIM(q.correct_text) = '')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id = q.id AND o.is_correct = 1)
|
||||||
|
`).all(testId).map(r => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
|
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
|
||||||
function list(req, res) {
|
function list(req, res) {
|
||||||
const { subject } = req.query;
|
const { subject } = req.query;
|
||||||
@@ -7,13 +19,16 @@ function list(req, res) {
|
|||||||
const args = [];
|
const args = [];
|
||||||
let where = '1=1';
|
let where = '1=1';
|
||||||
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
|
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
|
||||||
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
|
const isStudent = role === 'student' || role === 'free_student';
|
||||||
|
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
|
||||||
|
if (isStudent) { where += ' AND t.available_to_students = 1'; }
|
||||||
|
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
|
||||||
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
|
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
|
||||||
// не показываем их во вкладке «Тесты (шаблоны)» админки.
|
// не показываем их во вкладке «Тесты (шаблоны)» админки.
|
||||||
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
|
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
|
||||||
|
|
||||||
const rows = db.prepare(`
|
let rows = db.prepare(`
|
||||||
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
|
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
|
||||||
u.name AS creator_name,
|
u.name AS creator_name,
|
||||||
COUNT(tq.question_id) AS question_count
|
COUNT(tq.question_id) AS question_count
|
||||||
FROM tests t
|
FROM tests t
|
||||||
@@ -22,18 +37,19 @@ function list(req, res) {
|
|||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY t.id ORDER BY t.created_at DESC
|
GROUP BY t.id ORDER BY t.created_at DESC
|
||||||
`).all(...args);
|
`).all(...args);
|
||||||
|
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
|
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
|
||||||
function create(req, res) {
|
function create(req, res) {
|
||||||
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
|
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
|
||||||
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
|
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
|
||||||
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
|
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
|
||||||
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
|
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
|
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
|
||||||
res.status(201).json({ id: r.lastInsertRowid });
|
res.status(201).json({ id: r.lastInsertRowid });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +61,18 @@ function getOne(req, res) {
|
|||||||
`).get(req.params.id);
|
`).get(req.params.id);
|
||||||
if (!t) return res.status(404).json({ error: 'Not found' });
|
if (!t) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
// Доступ как в list(): ученик видит только помеченные доступными и не служебные
|
||||||
|
// экзамен-варианты; учитель — только свои; админ — все. Иначе по id можно было бы
|
||||||
|
// прочитать тексты заданий из черновиков/вариантов.
|
||||||
|
const { role, id: uid } = req.user;
|
||||||
|
const isStudent = role === 'student' || role === 'free_student';
|
||||||
|
if (isStudent) {
|
||||||
|
const isVariant = db.prepare('SELECT 1 FROM exam9_variant_tests WHERE test_id = ?').get(t.id);
|
||||||
|
if (!t.available_to_students || isVariant) return res.status(404).json({ error: 'Not found' });
|
||||||
|
} else if (role !== 'admin' && t.created_by !== uid) {
|
||||||
|
return res.status(404).json({ error: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
const questions = db.prepare(`
|
const questions = db.prepare(`
|
||||||
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
|
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
|
||||||
tp.name AS topic, s.name AS subject_name,
|
tp.name AS topic, s.name AS subject_name,
|
||||||
@@ -76,13 +104,23 @@ function getOne(req, res) {
|
|||||||
|
|
||||||
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
|
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
|
||||||
function update(req, res) {
|
function update(req, res) {
|
||||||
const { title, subject_slug, description, show_answers, time_limit } = req.body;
|
const b = req.body;
|
||||||
const t = req.resource; // ownership verified by requireOwnership middleware
|
const t = req.resource; // ownership verified by requireOwnership middleware
|
||||||
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
|
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
|
||||||
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
|
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
|
||||||
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
|
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
|
||||||
tl !== undefined ? tl : t.time_limit,
|
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
|
||||||
t.id);
|
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
|
||||||
|
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
|
||||||
|
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
|
||||||
|
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
|
||||||
|
// Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа.
|
||||||
|
if (available === 1) {
|
||||||
|
const broken = unanswerableInTest(t.id);
|
||||||
|
if (broken.length) return res.status(400).json({ error: `Нельзя опубликовать: ${broken.length} вопрос(ов) без правильного ответа. Исправьте их в банке.`, brokenQuestions: broken });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
|
||||||
|
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
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);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 081: Practice progress (ИИ-тренажёр, Фаза 0).
|
||||||
|
--
|
||||||
|
-- Прогресс ученика по НАВЫКАМ тренажёра. Навык = skill генератора
|
||||||
|
-- (напр. 'linear-basic'); задачи генерируются на клиенте детерминированно
|
||||||
|
-- и проверяются подстановкой — сервер хранит лишь агрегаты результата.
|
||||||
|
--
|
||||||
|
-- На каждую попытку клиент шлёт { skill, correct }. Сервер делает upsert:
|
||||||
|
-- solved — всего верных ответов
|
||||||
|
-- attempts — всего попыток (верных и нет)
|
||||||
|
-- cur_streak — текущая серия верных подряд (обнуляется ошибкой)
|
||||||
|
-- best_streak — лучшая серия
|
||||||
|
-- mastered — 1, как только cur_streak достиг порога (липкое)
|
||||||
|
-- UNIQUE(user_id, skill) — одна строка на пару ученик-навык.
|
||||||
|
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с учеником.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS practice_progress (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
skill TEXT NOT NULL, -- идентификатор навыка генератора
|
||||||
|
solved INTEGER NOT NULL DEFAULT 0, -- всего верных ответов
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0, -- всего попыток
|
||||||
|
cur_streak INTEGER NOT NULL DEFAULT 0, -- текущая серия верных подряд
|
||||||
|
best_streak INTEGER NOT NULL DEFAULT 0, -- лучшая серия
|
||||||
|
mastered INTEGER NOT NULL DEFAULT 0, -- 1, когда серия достигала порога
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (user_id, skill)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_practice_progress_user ON practice_progress (user_id);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 082: SR-поля тренажёра (интервальное повторение по навыкам, Фаза 2).
|
||||||
|
--
|
||||||
|
-- К practice_progress добавляем Leitner-«коробку» и срок следующего показа:
|
||||||
|
-- box — уровень 0..5 (выше = увереннее освоено, реже повторяем).
|
||||||
|
-- due_at — когда навык снова стоит показать (datetime). NULL = «как можно скорее».
|
||||||
|
-- На верный ответ box растёт и срок отодвигается; на ошибку box сбрасывается в 0
|
||||||
|
-- и срок = сейчас (навык всплывёт первым при следующем заходе). Адаптивный
|
||||||
|
-- подборщик на клиенте показывает «просроченные» навыки (due_at <= now) раньше.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE practice_progress ADD COLUMN box INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE practice_progress ADD COLUMN due_at TEXT;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 083: Пул текстовых задач тренажёра (Уровень 1, Фаза 3).
|
||||||
|
--
|
||||||
|
-- Кэш сгенерированных LLM и ПРОВЕРЕННЫХ задач: модель предлагает условие +
|
||||||
|
-- уравнение (lhs/rhs) + корень, сервер подтверждает подстановкой (practiceVerify)
|
||||||
|
-- и только тогда пишет сюда. Ученик берёт готовые задачи из пула (не платим за
|
||||||
|
-- генерацию на каждый показ). story и заметки решения уже санитизированы.
|
||||||
|
-- status: approved (видна ученикам) | draft (на ревью учителю).
|
||||||
|
-- created_by ON DELETE SET NULL — задача переживает удаление автора.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS practice_problems (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
topic TEXT NOT NULL, -- word-linear | word-proportion | word-percent
|
||||||
|
skill TEXT NOT NULL, -- ключ навыка (для прогресса)
|
||||||
|
difficulty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
story TEXT NOT NULL, -- условие словами (экранировано)
|
||||||
|
lhs TEXT NOT NULL, -- левая часть уравнения (выражение от x)
|
||||||
|
rhs TEXT NOT NULL, -- правая часть
|
||||||
|
answer_var TEXT NOT NULL DEFAULT 'x',
|
||||||
|
answer REAL NOT NULL, -- проверенный корень
|
||||||
|
solution_json TEXT, -- шаги [{note,tex}] (JSON)
|
||||||
|
status TEXT NOT NULL DEFAULT 'approved', -- approved | draft
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_practice_problems_skill ON practice_problems (skill, status);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 084: Пользовательские генераторы тренажёра (конструктор, Roadmap P13).
|
||||||
|
--
|
||||||
|
-- Учитель создаёт ПАРАМЕТРИЧЕСКИЙ генератор задач — это ДАННЫЕ (spec_json):
|
||||||
|
-- диапазоны pick, формулы derive, шаблоны lhs/rhs, ответ, шаги решения. На
|
||||||
|
-- клиенте спек исполняет БЕЗОПАСНЫЙ SimExpr (⛔ без eval), на сервере он только
|
||||||
|
-- хранится и валидируется по структуре/лимитам (НЕ исполняется). Прогресс по
|
||||||
|
-- такому навыку ключуется как 'cg<id>'.
|
||||||
|
-- status: draft (видит только автор) | published (видят и ученики).
|
||||||
|
-- owner_id ON DELETE CASCADE — генераторы удаляются вместе с автором.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_generators (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
topic TEXT NOT NULL DEFAULT 'custom',
|
||||||
|
spec_json TEXT NOT NULL, -- полный спек генератора (данные)
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_generators_owner ON custom_generators (owner_id, 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 */
|
/* Alias: requireAuth = authMiddleware */
|
||||||
const requireAuth = authMiddleware;
|
const requireAuth = authMiddleware;
|
||||||
|
|
||||||
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
|
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
|
||||||
|
|||||||
@@ -29,6 +29,19 @@
|
|||||||
* if (feats.exam9 === false) { ... }
|
* if (feats.exam9 === false) { ... }
|
||||||
*/
|
*/
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
// Админ-оверрайд: requireFeature идёт ДО authMiddleware (req.user ещё нет),
|
||||||
|
// поэтому декодируем Bearer-токен сами — админ открывает и отключённые модули
|
||||||
|
// (зеркалит фронтовый _isAdminUser, см. project_gamification_killswitch).
|
||||||
|
function _isAdminReq(req) {
|
||||||
|
try {
|
||||||
|
const h = req.headers.authorization || '';
|
||||||
|
if (!h.startsWith('Bearer ')) return false;
|
||||||
|
const p = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
|
return !!(p && p.role === 'admin');
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||||
const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
|
const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
|
||||||
@@ -41,7 +54,7 @@ function requireFeature(name) {
|
|||||||
const settingKey = `feature_${name}_enabled`;
|
const settingKey = `feature_${name}_enabled`;
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const row = _stmtSingle.get(settingKey);
|
const row = _stmtSingle.get(settingKey);
|
||||||
if (row && row.value === '0') {
|
if (row && row.value === '0' && !_isAdminReq(req)) { // админ проходит к API даже выключенного модуля
|
||||||
return res.status(404).json({ error: 'Feature disabled' });
|
return res.status(404).json({ error: 'Feature disabled' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -115,6 +115,28 @@ const PERMISSIONS = {
|
|||||||
label: 'Управление геймификацией',
|
label: 'Управление геймификацией',
|
||||||
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
|
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) ── */
|
/* ── Student (also applies to free_student — same keys, same defaults) ── */
|
||||||
'tests.free': {
|
'tests.free': {
|
||||||
@@ -160,6 +182,38 @@ const PERMISSIONS = {
|
|||||||
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
||||||
requires: ['simulations.access'],
|
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). */
|
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
|
||||||
@@ -169,15 +223,20 @@ const GROUP = {
|
|||||||
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
|
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
|
||||||
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
|
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
|
||||||
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
|
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
|
||||||
|
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
|
||||||
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
|
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
|
||||||
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
|
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
|
||||||
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
|
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
|
||||||
|
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
|
||||||
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
|
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
|
||||||
// student
|
// student
|
||||||
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
|
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
|
||||||
|
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
|
||||||
|
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
|
||||||
'profile.edit': 'Профиль',
|
'profile.edit': 'Профиль',
|
||||||
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
|
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
|
||||||
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
|
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
|
||||||
|
'flashcards.access': 'Контент', 'exam.access': 'Контент',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
|
|||||||
/* Everything below is admin-only */
|
/* Everything below is admin-only */
|
||||||
router.use(requireRole('admin'));
|
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.get('/assistant', ctrl.getAssistant);
|
||||||
router.put('/assistant', ctrl.saveAssistant);
|
router.put('/assistant', ctrl.saveAssistant);
|
||||||
router.post('/assistant/test', ctrl.testAssistant);
|
router.post('/assistant/test', ctrl.testAssistant);
|
||||||
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
||||||
router.get('/assistant/models', ctrl.getProviderModels);
|
router.get('/assistant/models', ctrl.getProviderModels);
|
||||||
|
router.post('/assistant/scan', ctrl.scanModels);
|
||||||
|
router.post('/assistant/probe', ctrl.probeModel);
|
||||||
|
router.post('/assistant/models/apply', ctrl.applyModels);
|
||||||
|
router.post('/assistant/health', ctrl.runHealth);
|
||||||
|
router.post('/assistant/index-system', ctrl.indexSystem);
|
||||||
router.get('/imggen', ctrl.getImggen);
|
router.get('/imggen', ctrl.getImggen);
|
||||||
router.put('/imggen', ctrl.saveImggen);
|
router.put('/imggen', ctrl.saveImggen);
|
||||||
router.post('/imggen/test', ctrl.testImggen);
|
router.post('/imggen/test', ctrl.testImggen);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
|
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
|
||||||
* 'pet' навешивается при монтировании в server.js. */
|
* 'pet' навешивается при монтировании в server.js. */
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const rateLimit = require('../middleware/rateLimit');
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
const ctrl = require('../controllers/assistantController');
|
const ctrl = require('../controllers/assistantController');
|
||||||
|
|
||||||
@@ -16,8 +16,10 @@ router.get('/context', ctrl.getContext);
|
|||||||
router.post('/seen', ctrl.markSeen);
|
router.post('/seen', ctrl.markSeen);
|
||||||
router.post('/dismiss', ctrl.dismiss);
|
router.post('/dismiss', ctrl.dismiss);
|
||||||
router.patch('/settings', ctrl.setSettings);
|
router.patch('/settings', ctrl.setSettings);
|
||||||
router.post('/ask', askLimiter, ctrl.ask);
|
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
|
||||||
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
|
router.post('/ask/stream', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.askStream);
|
||||||
|
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
|
||||||
|
router.post('/questions', requireRole('teacher', 'admin'), fcLimiter, ctrl.questionsFromText);
|
||||||
router.post('/feedback', ctrl.feedback);
|
router.post('/feedback', ctrl.feedback);
|
||||||
router.get('/memory', ctrl.getMemory);
|
router.get('/memory', ctrl.getMemory);
|
||||||
router.delete('/memory', ctrl.clearMemory);
|
router.delete('/memory', ctrl.clearMemory);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
const ctrl = require('../controllers/avatarController');
|
const ctrl = require('../controllers/avatarController');
|
||||||
|
|
||||||
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
|
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
|
||||||
@@ -13,7 +14,9 @@ const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: AVATARS_DIR,
|
destination: AVATARS_DIR,
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
// Расширение — из проверенного MIME (fileFilter уже сузил до image/*),
|
||||||
|
// НЕ из client-controlled originalname (иначе .html/.svg → stored-XSS).
|
||||||
|
const ext = safeExt(file.mimetype, '.png');
|
||||||
const name = crypto.randomBytes(16).toString('hex') + ext;
|
const name = crypto.randomBytes(16).toString('hex') + ext;
|
||||||
cb(null, name);
|
cb(null, name);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.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', requireRole('teacher','admin'), ctrl.classJournal);
|
||||||
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
|
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.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
|
||||||
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
|
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
|
||||||
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
|
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const router = require('express').Router();
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
const rateLimit = require('../middleware/rateLimit');
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
const c = require('../controllers/classroomController');
|
const c = require('../controllers/classroomController');
|
||||||
|
|
||||||
@@ -11,8 +12,9 @@ const _chatUploadsDir = path.join(__dirname, '../../uploads/chat');
|
|||||||
const _chatStorage = multer.diskStorage({
|
const _chatStorage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, _chatUploadsDir),
|
destination: (req, file, cb) => cb(null, _chatUploadsDir),
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
|
// Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS,
|
||||||
cb(null, crypto.randomBytes(14).toString('hex') + ext);
|
// если каталог chat начнут раздавать статикой).
|
||||||
|
cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const chatUpload = multer({
|
const chatUpload = multer({
|
||||||
@@ -47,7 +49,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
|
|||||||
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
||||||
|
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
router.post('/', ...teacher, c.createSession);
|
router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
|
||||||
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
||||||
router.get('/my/session', ...auth, c.getMySession);
|
router.get('/my/session', ...auth, c.getMySession);
|
||||||
router.get('/class/:classId/active', ...auth, c.getActiveSession);
|
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). */
|
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||||
const { requireFeature } = require('../middleware/features');
|
const { requireFeature } = require('../middleware/features');
|
||||||
const c = require('../controllers/customSimController');
|
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
|
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||||
router.get('/:id/related', c.related);
|
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
|
// @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
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
|
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const access = require('../services/contentAccess');
|
const access = require('../services/contentAccess');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
|
||||||
|
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
|
||||||
|
router.use(requirePermissionForStudents('exam.access'));
|
||||||
|
|
||||||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||||
@@ -57,6 +60,10 @@ const VARIANT_LABEL = {
|
|||||||
115: 'ЦТ-2019',
|
115: 'ЦТ-2019',
|
||||||
116: 'ЦТ-2020',
|
116: 'ЦТ-2020',
|
||||||
117: 'ЦТ-2021',
|
117: 'ЦТ-2021',
|
||||||
|
118: 'ЦТ-2017',
|
||||||
|
119: 'ЦТ-2013',
|
||||||
|
120: 'ЦТ-2012',
|
||||||
|
121: 'ЦТ-2011',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
|
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fc = require('../controllers/flashcardController');
|
const fc = require('../controllers/flashcardController');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const { requireOwnership } = require('../middleware/ownership');
|
const { requireOwnership } = require('../middleware/ownership');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
|
|
||||||
/* ── multer для картинок карточек ───────────────────────────────────────
|
/* ── multer для картинок карточек ───────────────────────────────────────
|
||||||
Файлы складываем в backend/uploads/flashcards, отдаём статикой через
|
Файлы складываем в backend/uploads/flashcards, отдаём статикой через
|
||||||
@@ -18,8 +19,8 @@ if (!fs.existsSync(_fcUploadsDir)) fs.mkdirSync(_fcUploadsDir, { recursive: true
|
|||||||
const _fcStorage = multer.diskStorage({
|
const _fcStorage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, _fcUploadsDir),
|
destination: (req, file, cb) => cb(null, _fcUploadsDir),
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
|
// Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS).
|
||||||
cb(null, crypto.randomBytes(14).toString('hex') + (ext || '.png'));
|
cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fcUpload = multer({
|
const fcUpload = multer({
|
||||||
@@ -30,6 +31,9 @@ const fcUpload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
|
||||||
|
// учитель/админ проходят всегда (создают и раздают колоды).
|
||||||
|
router.use(requirePermissionForStudents('flashcards.access'));
|
||||||
|
|
||||||
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
|
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
|
||||||
|
|
||||||
@@ -45,8 +49,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
|||||||
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
||||||
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
|
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
|
||||||
router.get ('/decks/:id/shares', fc.listShares);
|
router.get ('/decks/:id/shares', fc.listShares);
|
||||||
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
|
router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
|
||||||
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
|
router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
|
||||||
router.get ('/decks/:id/study', fc.getStudySession);
|
router.get ('/decks/:id/study', fc.getStudySession);
|
||||||
router.put ('/cards/:id', fc.updateCard);
|
router.put ('/cards/:id', fc.updateCard);
|
||||||
router.delete('/cards/:id', fc.deleteCard);
|
router.delete('/cards/:id', fc.deleteCard);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const { requireFeature } = require('../middleware/features');
|
const { requireFeature } = require('../middleware/features');
|
||||||
const c = require('../controllers/gamesController');
|
const c = require('../controllers/gamesController');
|
||||||
|
|
||||||
const hangman = requireFeature('hangman');
|
const hangman = requireFeature('hangman');
|
||||||
const crossword = requireFeature('crossword');
|
const crossword = requireFeature('crossword');
|
||||||
|
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
|
||||||
|
const playable = requirePermissionForStudents('games.play');
|
||||||
|
|
||||||
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord);
|
router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
|
||||||
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete);
|
router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
|
||||||
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate);
|
router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
|
||||||
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete);
|
router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||||
const c = require('../controllers/liveController');
|
const c = require('../controllers/liveController');
|
||||||
|
|
||||||
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
|
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.get('/:id', ...teacher, c.getSession);
|
||||||
router.put('/:id/question', ...teacher, c.setQuestion);
|
router.put('/:id/question', ...teacher, c.setQuestion);
|
||||||
router.get('/:id/results', ...teacher, c.results);
|
router.get('/:id/results', ...teacher, c.results);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const c = require('../controllers/studentMaterialsController');
|
const c = require('../controllers/studentMaterialsController');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
@@ -10,7 +10,8 @@ router.use(authMiddleware);
|
|||||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||||
|
|
||||||
router.get('/', c.list);
|
router.get('/', c.list);
|
||||||
router.post('/', c.create);
|
// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
|
||||||
|
router.post('/', requirePermissionForStudents('materials.save'), c.create);
|
||||||
|
|
||||||
// Collections (folders) — literal '/collections' prefix before '/:id'
|
// Collections (folders) — literal '/collections' prefix before '/:id'
|
||||||
router.post('/collections', c.createCollection);
|
router.post('/collections', c.createCollection);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use strict';
|
||||||
|
/* /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
|
||||||
|
* Все роуты — auth-only (тренируются ученики). router.use(authMiddleware)
|
||||||
|
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
|
||||||
|
* межпользовательских роутов, проверка владения не требуется. */
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const c = require('../controllers/practiceController');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.get('/progress', c.listProgress);
|
||||||
|
router.post('/attempt', c.submitAttempt);
|
||||||
|
router.post('/explain', c.explainProblem); // ИИ-репетитор: разбор ошибки / подсказка (ученикам)
|
||||||
|
|
||||||
|
// Текстовые задачи (Уровень 1): пул читают все; генерирует/авторит учитель/админ.
|
||||||
|
router.get('/pool', c.listPool);
|
||||||
|
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
|
||||||
|
router.post('/author', requireRole('teacher', 'admin'), c.authorProblem);
|
||||||
|
router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass);
|
||||||
|
|
||||||
|
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
|
||||||
|
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
|
||||||
|
|
||||||
|
// Конструктор генераторов (P13): чтение — own+published (ученики видят published);
|
||||||
|
// СОЗДАНИЕ/правка — ТОЛЬКО админ (конструктор — админский инструмент).
|
||||||
|
const cg = require('../controllers/customGeneratorController');
|
||||||
|
router.get('/generators', cg.genList);
|
||||||
|
router.post('/generators', requireRole('admin'), cg.genCreate);
|
||||||
|
router.get('/generators/:id', cg.genGet); // @public-by-design: own/published в хендлере
|
||||||
|
router.put('/generators/:id', requireRole('admin'), cg.genUpdate);
|
||||||
|
router.delete('/generators/:id', requireRole('admin'), cg.genDelete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
|
|||||||
|
|
||||||
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
|
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
|
||||||
const { default_mode, default_count, default_test_id } = req.body;
|
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))
|
if (default_mode && !valid_modes.includes(default_mode))
|
||||||
return res.status(400).json({ error: 'Invalid mode' });
|
return res.status(400).json({ error: 'Invalid mode' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||||
const ctrl = require('../controllers/submissionsController');
|
const ctrl = require('../controllers/submissionsController');
|
||||||
const { fixUtf8Name } = require('../utils/fixUtf8');
|
const { fixUtf8Name } = require('../utils/fixUtf8');
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ const upload = multer({
|
|||||||
/* ── routes ─────────────────────────────────────────────────────────── */
|
/* ── routes ─────────────────────────────────────────────────────────── */
|
||||||
router.use(authMiddleware);
|
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('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
|
||||||
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
|
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
|
||||||
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
|
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.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
|
||||||
router.get('/:id/download', ctrl.downloadSubmission);
|
router.get('/:id/download', ctrl.downloadSubmission);
|
||||||
router.delete('/:id', ctrl.deleteSubmission);
|
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;
|
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,9 @@ app.use('/api/lab', labRoutes);
|
|||||||
app.use('/api/materials', require('./routes/materials'));
|
app.use('/api/materials', require('./routes/materials'));
|
||||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||||
app.use('/api/game', require('./routes/game'));
|
app.use('/api/game', require('./routes/game'));
|
||||||
|
app.use('/api/practice', requireFeature('trainer'), require('./routes/practice'));
|
||||||
|
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/prep', require('./routes/prep'));
|
||||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
|
|
||||||
@@ -533,6 +536,9 @@ require('./ws-server').attach(server);
|
|||||||
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
|
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
|
||||||
try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); }
|
try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); }
|
||||||
|
|
||||||
|
/* ── Авто-здоровье LLM-провайдеров Квантика: пинг + авто-понижение упавшего активного ── */
|
||||||
|
try { require('./assistant-health').schedule(); } catch (e) { logger.error('assistant-health schedule error', { err: e.message }); }
|
||||||
|
|
||||||
/* ── Graceful shutdown ── */
|
/* ── Graceful shutdown ── */
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
logger.info(`${signal} received — shutting down gracefully`);
|
logger.info(`${signal} received — shutting down gracefully`);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ИИ-репетитор: объяснение ОШИБКИ и наводящие ПОДСКАЗКИ (направление A).
|
||||||
|
*
|
||||||
|
* Безопасность через grounding: модели ДАЮТСЯ задача, правильный ответ и готовые
|
||||||
|
* шаги решения (всё вычислено детерминированно движком). Модель НЕ считает —
|
||||||
|
* только ОБЪЯСНЯЕТ простым языком. Поэтому даже слабая модель не выдаст неверную
|
||||||
|
* математику: правильный ответ ей известен. Текст ответа модели обрезается и
|
||||||
|
* экранируется (рендерится как текст на клиенте). LLM-вызов инъектируется
|
||||||
|
* (opts.ask) — тесты подают фейковую модель; реальный берёт callLLMFailover лениво.
|
||||||
|
*
|
||||||
|
* mode:
|
||||||
|
* 'mistake' — объяснить, в чём ошибка ученика, и как исправить (можно назвать ответ).
|
||||||
|
* 'hint' — одна наводящая подсказка, БЕЗ раскрытия итогового ответа.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_DISPLAY = 400, MAX_ANSWER = 80, MAX_STEP = 300, MAX_STEPS = 8, MAX_OUT = 700;
|
||||||
|
|
||||||
|
function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
function stepsText(steps) {
|
||||||
|
if (!Array.isArray(steps)) return '';
|
||||||
|
return steps.slice(0, MAX_STEPS).map(function (st, i) {
|
||||||
|
st = st || {};
|
||||||
|
var note = clip(st.note, MAX_STEP), tex = clip(st.tex, MAX_STEP);
|
||||||
|
return (i + 1) + ') ' + [note, tex].filter(Boolean).join(' ');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessages(problem, studentAnswer, mode) {
|
||||||
|
problem = problem || {};
|
||||||
|
var display = clip(problem.display, MAX_DISPLAY);
|
||||||
|
var answer = clip(problem.answer, MAX_ANSWER);
|
||||||
|
var steps = stepsText(problem.solution || problem.steps);
|
||||||
|
|
||||||
|
var sys = (mode === 'hint')
|
||||||
|
? ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' +
|
||||||
|
'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ и шаги решения. Дай ОДНУ короткую наводящую ' +
|
||||||
|
'подсказку (наводящий вопрос или первый шаг) на русском, 1–2 предложения, простым языком. ' +
|
||||||
|
'НЕ называй итоговый ответ и не приводи всё решение — только направь мысль. ' +
|
||||||
|
'Опирайся на данные шаги, не выдумывай.')
|
||||||
|
: ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' +
|
||||||
|
'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ, шаги решения и ОТВЕТ УЧЕНИКА (неверный). ' +
|
||||||
|
'Коротко (2–4 предложения, по-русски, простым языком, без укоров) объясни, в чём именно ' +
|
||||||
|
'ошибка ученика и как её исправить. Опирайся на данные шаги — не выдумывай математику. ' +
|
||||||
|
'В конце можешь назвать правильный ответ.');
|
||||||
|
|
||||||
|
var user = 'Задача: ' + display + '\nПравильный ответ: ' + answer +
|
||||||
|
(steps ? ('\nШаги решения:\n' + steps) : '');
|
||||||
|
if (mode !== 'hint') user += '\nОтвет ученика: ' + clip(studentAnswer, MAX_ANSWER) + '\nОбъясни ошибку.';
|
||||||
|
else user += '\nДай подсказку, не раскрывая ответ.';
|
||||||
|
|
||||||
|
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Чистим ответ модели: убираем markdown-обёртки, обрезаем, экранируем. */
|
||||||
|
function cleanText(text) {
|
||||||
|
var s = String(text == null ? '' : text).trim();
|
||||||
|
s = s.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim(); // снять кодовые блоки
|
||||||
|
return esc(clip(s, MAX_OUT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultAsk(messages, maxTokens) {
|
||||||
|
const { callLLMFailover } = require('../controllers/assistantController');
|
||||||
|
return callLLMFailover(messages, maxTokens, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вернёт { ok, text } или { ok:false, error }. */
|
||||||
|
async function explain(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var mode = (opts.mode === 'hint') ? 'hint' : 'mistake';
|
||||||
|
var ask = opts.ask || _defaultAsk;
|
||||||
|
var messages = buildMessages(opts.problem, opts.studentAnswer, mode);
|
||||||
|
|
||||||
|
var res;
|
||||||
|
try { res = await ask(messages, 360); }
|
||||||
|
catch (e) { return { ok: false, error: 'ask-threw' }; }
|
||||||
|
if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off' };
|
||||||
|
|
||||||
|
var text = cleanText(res.text);
|
||||||
|
if (!text) return { ok: false, error: 'empty' };
|
||||||
|
return { ok: true, text: text, mode: mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { explain, buildMessages, cleanText };
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Генерация ТЕКСТОВЫХ задач (Уровень 1) через LLM с ОБЯЗАТЕЛЬНОЙ проверкой.
|
||||||
|
*
|
||||||
|
* LLM предлагает { story, lhs, rhs, answer, solution }; сервер компилирует
|
||||||
|
* выражения через SimExpr и ПОДСТАВЛЯЕТ корень (practiceVerify). Не сходится —
|
||||||
|
* авторетрай с фидбэком об ошибке; не починилось за N попыток — задача
|
||||||
|
* отбрасывается и ученику НЕ попадает (инвариант корректности). Текст условия и
|
||||||
|
* заметки решения экранируются; выражения идут только в SimExpr (без eval).
|
||||||
|
*
|
||||||
|
* LLM-вызов инъектируется (opts.ask) — тесты подают фейковую модель, реальный
|
||||||
|
* вызов берёт провайдеров ассистента (callLLMFailover) лениво.
|
||||||
|
*/
|
||||||
|
const { verifyRoot, compileOk } = require('../utils/practiceVerify');
|
||||||
|
|
||||||
|
const MAX_STORY = 600, MAX_EXPR = 200, MAX_STEPS = 8, MAX_NOTE = 300;
|
||||||
|
|
||||||
|
function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
function sanitizeText(s, n) { return esc(clip(s, n)); }
|
||||||
|
|
||||||
|
const TOPIC_HINTS = {
|
||||||
|
'word-linear': 'линейное уравнение вида a·x + b = c (одна неизвестная x); задачи на возраст, числа, покупки',
|
||||||
|
'word-proportion': 'пропорцию a/b = c/x (задачи на части, рецепты, скорость)',
|
||||||
|
'word-percent': 'нахождение процента от числа или числа по проценту'
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMessages(topic, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const hint = TOPIC_HINTS[topic] || TOPIC_HINTS['word-linear'];
|
||||||
|
const sys =
|
||||||
|
'Ты генератор школьных задач по математике (7 класс). Возвращай СТРОГО один JSON-объект, ' +
|
||||||
|
'без markdown и пояснений. Формат: ' +
|
||||||
|
'{"story":"<условие словами на русском>","lhs":"<левая часть уравнения как выражение от x>",' +
|
||||||
|
'"rhs":"<правая часть>","answer":<целое число>,"answerVar":"x",' +
|
||||||
|
'"solution":[{"note":"<пояснение шага словами>","tex":"<один шаг как равенство, выражение>"}]}. ' +
|
||||||
|
'Уравнение должно соответствовать условию и иметь целый корень. В lhs/rhs/tex — ТОЛЬКО ' +
|
||||||
|
'математические выражения (символы + - * / ( ) и x), без слов.';
|
||||||
|
let user = 'Составь текстовую задачу на ' + hint + '. Корень — целое число. Верни только JSON.';
|
||||||
|
if (opts.feedback) user += ' Предыдущая попытка отклонена. ' + opts.feedback + ' Верни исправленный JSON.';
|
||||||
|
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Достаём первый JSON-объект из ответа модели (терпимо к обёрткам/markdown). */
|
||||||
|
function parseProblem(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const m = String(text).match(/\{[\s\S]*\}/);
|
||||||
|
if (!m) return null;
|
||||||
|
try { return JSON.parse(m[0]); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Валидация структуры + КОРРЕКТНОСТЬ (подстановка) + санитизация. */
|
||||||
|
function validateAndVerify(obj) {
|
||||||
|
if (!obj || typeof obj !== 'object') return { ok: false, reason: 'no-json' };
|
||||||
|
const story = obj.story, lhs = obj.lhs, rhs = obj.rhs;
|
||||||
|
const answerVar = (typeof obj.answerVar === 'string' && /^[a-z]$/.test(obj.answerVar)) ? obj.answerVar : 'x';
|
||||||
|
const answer = Number(obj.answer);
|
||||||
|
|
||||||
|
if (typeof story !== 'string' || !story.trim()) return { ok: false, reason: 'no-story' };
|
||||||
|
if (typeof lhs !== 'string' || typeof rhs !== 'string') return { ok: false, reason: 'no-expr' };
|
||||||
|
if (lhs.length > MAX_EXPR || rhs.length > MAX_EXPR) return { ok: false, reason: 'expr-too-long' };
|
||||||
|
if (!Number.isFinite(answer)) return { ok: false, reason: 'bad-answer' };
|
||||||
|
if (!compileOk(lhs) || !compileOk(rhs)) return { ok: false, reason: 'expr-parse' };
|
||||||
|
|
||||||
|
const v = verifyRoot(lhs, rhs, answerVar, answer);
|
||||||
|
if (!v.ok) return { ok: false, reason: 'verify-failed' + (v.residual != null ? ' (residual ' + v.residual.toFixed(4) + ')' : '') };
|
||||||
|
|
||||||
|
let solution = [];
|
||||||
|
if (Array.isArray(obj.solution)) {
|
||||||
|
solution = obj.solution.slice(0, MAX_STEPS).map(function (st) {
|
||||||
|
st = st || {};
|
||||||
|
const out = { note: sanitizeText(st.note, MAX_NOTE) };
|
||||||
|
if (typeof st.tex === 'string' && st.tex.length <= MAX_EXPR && compileOk(st.tex)) out.tex = clip(st.tex, MAX_EXPR);
|
||||||
|
else out.tex = '';
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
problem: {
|
||||||
|
story: sanitizeText(story, MAX_STORY),
|
||||||
|
lhs: clip(lhs, MAX_EXPR), rhs: clip(rhs, MAX_EXPR),
|
||||||
|
answerVar: answerVar, answer: answer, solution: solution
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultAsk(messages, maxTokens) {
|
||||||
|
// лениво, чтобы не тянуть assistantController (и провайдеров) в юнит-тестах
|
||||||
|
const { callLLMFailover } = require('../controllers/assistantController');
|
||||||
|
return callLLMFailover(messages, maxTokens, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Главная: вернёт { ok, problem, attempts } или { ok:false, error, reason, attempts }. */
|
||||||
|
async function generate(topic, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const ask = opts.ask || _defaultAsk;
|
||||||
|
const maxRetries = Math.max(1, Math.min(opts.maxRetries || 3, 5));
|
||||||
|
let feedback = '', lastReason = 'off';
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
let res;
|
||||||
|
try { res = await ask(buildMessages(topic, { feedback }), 420); }
|
||||||
|
catch (e) { return { ok: false, error: 'ask-threw', attempts: i }; }
|
||||||
|
if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off', attempts: i };
|
||||||
|
|
||||||
|
const obj = parseProblem(res.text);
|
||||||
|
if (!obj) { feedback = 'Верни строго один JSON-объект без текста вокруг.'; lastReason = 'no-json'; continue; }
|
||||||
|
|
||||||
|
const v = validateAndVerify(obj);
|
||||||
|
if (v.ok) return { ok: true, problem: v.problem, attempts: i + 1 };
|
||||||
|
|
||||||
|
lastReason = v.reason;
|
||||||
|
feedback = 'Причина: ' + v.reason + '. Проверь, что при ' + answerVarOf(obj) + '=' + obj.answer + ' левая часть равна правой.';
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'unverified', reason: lastReason, attempts: maxRetries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerVarOf(obj) { return (obj && typeof obj.answerVar === 'string') ? obj.answerVar : 'x'; }
|
||||||
|
|
||||||
|
module.exports = { generate, validateAndVerify, parseProblem, buildMessages };
|
||||||
@@ -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 };
|
||||||
@@ -18,6 +18,20 @@ const MAGIC = [
|
|||||||
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
|
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* Канонические расширения по проверенному MIME. Имя файла на диске берём
|
||||||
|
* ОТСЮДА, а не из client-controlled originalname, иначе можно сохранить
|
||||||
|
* .html/.svg и получить stored-XSS при раздаче статикой. */
|
||||||
|
const EXT_FOR_MIME = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'application/pdf': '.pdf',
|
||||||
|
};
|
||||||
|
function safeExt(declaredMime, fallback) {
|
||||||
|
return EXT_FOR_MIME[declaredMime] || fallback || '';
|
||||||
|
}
|
||||||
|
|
||||||
function checkMagicBytes(filePath, declaredMime) {
|
function checkMagicBytes(filePath, declaredMime) {
|
||||||
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
|
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
|
||||||
const rules = MAGIC.filter(m => m.mime === declaredMime);
|
const rules = MAGIC.filter(m => m.mime === declaredMime);
|
||||||
@@ -35,4 +49,4 @@ function checkMagicBytes(filePath, declaredMime) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkMagicBytes };
|
module.exports = { checkMagicBytes, safeExt, EXT_FOR_MIME };
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Серверная проверка задач тренажёра через SimExpr — тот же безопасный
|
||||||
|
* вычислитель, что на клиенте (⛔ без eval/new Function). Гарантирует, что любая
|
||||||
|
* задача (от LLM или учителя) КОРРЕКТНА: подставляем заявленный корень в обе
|
||||||
|
* части уравнения и сверяем с допуском. SimExpr — чистый (без DOM), грузится в
|
||||||
|
* Node через require: его IIFE цепляется к globalThis.SimExpr. */
|
||||||
|
require('../../../frontend/js/labs/_sim_expr.js'); // → globalThis.SimExpr
|
||||||
|
const SimExpr = globalThis.SimExpr;
|
||||||
|
|
||||||
|
const EPS = 1e-7;
|
||||||
|
|
||||||
|
/* Компиляция выражения; null при синтаксической ошибке (мусор от модели). */
|
||||||
|
function compileOk(expr) {
|
||||||
|
if (typeof expr !== 'string') return null;
|
||||||
|
const c = SimExpr.compile(expr);
|
||||||
|
return (c && !c.error) ? c : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подстановочная проверка: lhs(var=value) ≈ rhs(var=value). */
|
||||||
|
function verifyRoot(lhs, rhs, varName, value) {
|
||||||
|
const cl = compileOk(lhs), cr = compileOk(rhs);
|
||||||
|
if (!cl || !cr) return { ok: false, reason: 'parse' };
|
||||||
|
if (typeof value !== 'number' || !isFinite(value)) return { ok: false, reason: 'bad-value' };
|
||||||
|
const env = {}; env[varName || 'x'] = value;
|
||||||
|
const L = cl.fn(env), R = cr.fn(env);
|
||||||
|
const residual = Math.abs(L - R);
|
||||||
|
const scale = Math.max(1, Math.abs(L), Math.abs(R));
|
||||||
|
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SimExpr, compileOk, verifyRoot };
|
||||||
@@ -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,106 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Tests: конструктор генераторов тренажёра (P13) — валидация + CRUD + доступ.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, inject, getToken, cleanup } = require('./setup');
|
||||||
|
const cg = require('../src/controllers/customGeneratorController');
|
||||||
|
|
||||||
|
app.use('/api/practice', require('../src/routes/practice'));
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
const SPEC = {
|
||||||
|
title: 'Моё уравнение', topic: 'custom', kind: 'solve',
|
||||||
|
pick: { a: [2, 9], b: [1, 20], root: [-9, 9] },
|
||||||
|
derive: { c: 'a*root + b', cmb: 'a*root' },
|
||||||
|
require: 'root != 0',
|
||||||
|
lhs: '{a}*x + {b}', rhs: '{c}', answer: 'root', integerAnswer: true,
|
||||||
|
solution: [{ note: 'делим на {a}', tex: 'x = {cmb} / {a}' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateGenSpec', () => {
|
||||||
|
it('принимает корректный спек', () => {
|
||||||
|
const v = cg.validateGenSpec(SPEC);
|
||||||
|
assert.equal(v.ok, true, v.error);
|
||||||
|
assert.equal(v.clean.kind, 'solve');
|
||||||
|
assert.deepEqual(v.clean.pick.a, [2, 9]);
|
||||||
|
assert.equal(v.clean.integerAnswer, true);
|
||||||
|
});
|
||||||
|
it('отвергает без заголовка', () => {
|
||||||
|
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { title: '' })).ok, false);
|
||||||
|
});
|
||||||
|
it('фильтрует нецелые диапазоны pick', () => {
|
||||||
|
const v = cg.validateGenSpec(Object.assign({}, SPEC, { pick: { a: [1.5, 9], b: [1, 20] } }));
|
||||||
|
assert.equal(v.ok, true);
|
||||||
|
assert.equal(v.clean.pick.a, undefined, 'нецелый диапазон отброшен');
|
||||||
|
assert.deepEqual(v.clean.pick.b, [1, 20]);
|
||||||
|
});
|
||||||
|
it('отвергает слишком большой спек', () => {
|
||||||
|
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { display: 'x'.repeat(30000) })).ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/practice/generators CRUD (конструктор — только админ)', () => {
|
||||||
|
let admin, other, teacher, student, gid;
|
||||||
|
before(async () => {
|
||||||
|
admin = (await getToken('admin')).token;
|
||||||
|
other = (await getToken('admin')).token;
|
||||||
|
teacher = (await getToken('teacher')).token;
|
||||||
|
student = (await getToken('student')).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('админ создаёт генератор', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, admin);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.ok(/^cg\d+$/.test(res.body.generator.id), 'id вида cg<dbid>');
|
||||||
|
gid = res.body.generator.dbid;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('учителю создавать запрещено (403)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученику создавать запрещено (403)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, student);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('невалидный спек → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, admin);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('автор видит свой генератор в списке', async () => {
|
||||||
|
const res = await inject('GET', '/api/practice/generators', null, admin);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.ok(res.body.generators.some(g => g.dbid === gid), 'свой генератор в списке');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('чужой draft не виден другому админу', async () => {
|
||||||
|
const res = await inject('GET', '/api/practice/generators', null, other);
|
||||||
|
assert.ok(!res.body.generators.some(g => g.dbid === gid), 'чужой draft скрыт');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('учителю изменять запрещено (403, роль)', async () => {
|
||||||
|
const res = await inject('PUT', '/api/practice/generators/' + gid, { spec: SPEC }, teacher);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('публикация делает генератор видимым другим (и ученику)', async () => {
|
||||||
|
const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, admin);
|
||||||
|
assert.equal(pub.status, 200);
|
||||||
|
assert.equal(pub.body.generator.status, 'published');
|
||||||
|
const res = await inject('GET', '/api/practice/generators', null, student);
|
||||||
|
assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден ученику');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('автор удаляет свой генератор', async () => {
|
||||||
|
const res = await inject('DELETE', '/api/practice/generators/' + gid, null, admin);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const after = await inject('GET', '/api/practice/generators/' + gid, null, admin);
|
||||||
|
assert.equal(after.status, 404, 'после удаления 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Tests: ИИ-репетитор тренажёра (разбор ошибки / подсказка).
|
||||||
|
* - explain (LLM застаблен): mistake/hint дают текст; grounding (ответ в промпте); off → not ok.
|
||||||
|
* - cleanText: снимает markdown-обёртки и экранирует HTML.
|
||||||
|
* - endpoint /explain: auth-only (ученику доступен); без провайдера → 503; без задачи → 400.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, inject, getToken, cleanup } = require('./setup');
|
||||||
|
const svc = require('../src/services/practiceExplainService');
|
||||||
|
|
||||||
|
app.use('/api/practice', require('../src/routes/practice'));
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
const PROBLEM = { display: '3x + 7 = 22', answer: 'x = 5', solution: [{ note: 'Переносим 7', tex: '3*x = 15' }, { note: 'Делим на 3', tex: 'x = 5' }] };
|
||||||
|
|
||||||
|
describe('practiceExplainService.explain (LLM застаблен)', () => {
|
||||||
|
it('mistake: возвращает текст и передаёт модели правильный ответ (grounding)', async () => {
|
||||||
|
let seen = null;
|
||||||
|
const ask = async (messages) => { seen = messages; return { text: 'Ты не поделил обе части на 3.' }; };
|
||||||
|
const r = await svc.explain({ problem: PROBLEM, studentAnswer: '15', mode: 'mistake', ask });
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.mode, 'mistake');
|
||||||
|
assert.ok(r.text.indexOf('поделил') !== -1);
|
||||||
|
const userMsg = seen.map(m => m.content).join('\n');
|
||||||
|
assert.ok(userMsg.indexOf('x = 5') !== -1, 'правильный ответ передан модели');
|
||||||
|
assert.ok(userMsg.indexOf('15') !== -1, 'ответ ученика передан модели');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hint: не просит раскрывать ответ ученика (нет строки «Ответ ученика»)', async () => {
|
||||||
|
let seen = null;
|
||||||
|
const ask = async (messages) => { seen = messages; return { text: 'Что нужно сделать со свободным членом 7?' }; };
|
||||||
|
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask });
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.mode, 'hint');
|
||||||
|
const sys = seen[0].content;
|
||||||
|
assert.ok(sys.indexOf('НЕ называй итоговый ответ') !== -1, 'hint-режим запрещает раскрывать ответ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('off-провайдер → not ok', async () => {
|
||||||
|
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask: async () => ({ text: null, error: 'off' }) });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.error, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanText снимает markdown и экранирует HTML', () => {
|
||||||
|
const out = svc.cleanText('```\nОшибка в знаке <b>тут</b>\n```');
|
||||||
|
assert.ok(out.indexOf('```') === -1, 'нет кодовых блоков');
|
||||||
|
assert.ok(out.indexOf('<b>') === -1 && out.indexOf('<b>') !== -1, 'HTML экранирован');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/practice/explain endpoint', () => {
|
||||||
|
let student;
|
||||||
|
before(async () => { student = (await getToken('student')).token; });
|
||||||
|
|
||||||
|
it('доступен ученику; без провайдера → 503', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/explain',
|
||||||
|
{ display: '3x + 7 = 22', answer: 'x = 5', steps: PROBLEM.solution, studentAnswer: '15', mode: 'mistake' }, student);
|
||||||
|
assert.equal(res.status, 503, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.error, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без текста задачи → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/explain', { display: '', mode: 'hint' }, student);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без авторизации → 401', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/explain', { display: '3x = 6', mode: 'hint' }, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Tests: текстовые задачи тренажёра (Уровень 1) — генерация + проверка + пул.
|
||||||
|
* - validateAndVerify: корректную принимает, неверный корень/мусор отвергает, текст экранирует.
|
||||||
|
* - generate (LLM застаблен): валидная с 1 попытки; ретраи; провал → unverified; провайдер off.
|
||||||
|
* - endpoints: /generate только учитель/админ (403 ученику; 503 без провайдера); /pool отдаёт пул.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
const gen = require('../src/services/practiceGenService');
|
||||||
|
|
||||||
|
app.use('/api/practice', require('../src/routes/practice'));
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
const GOOD = { story: 'Задумали число x: <b>3x + 4 = 19</b>. Найдите x.', lhs: '3*x + 4', rhs: '19', answer: 5, answerVar: 'x', solution: [{ note: 'Перенесём 4', tex: '3*x = 15' }] };
|
||||||
|
|
||||||
|
describe('practiceGenService.validateAndVerify', () => {
|
||||||
|
it('принимает корректную задачу и экранирует текст', () => {
|
||||||
|
const v = gen.validateAndVerify(GOOD);
|
||||||
|
assert.equal(v.ok, true, v.reason);
|
||||||
|
assert.equal(v.problem.answer, 5);
|
||||||
|
assert.ok(v.problem.story.indexOf('<b>') === -1 && v.problem.story.indexOf('<b>') !== -1, 'story escaped');
|
||||||
|
assert.equal(v.problem.solution[0].tex, '3*x = 15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отвергает неверный корень (подстановка не сходится)', () => {
|
||||||
|
const v = gen.validateAndVerify(Object.assign({}, GOOD, { answer: 6 }));
|
||||||
|
assert.equal(v.ok, false);
|
||||||
|
assert.ok(/verify-failed/.test(v.reason), v.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отвергает невалидное выражение', () => {
|
||||||
|
const v = gen.validateAndVerify(Object.assign({}, GOOD, { lhs: '3x +' }));
|
||||||
|
assert.equal(v.ok, false);
|
||||||
|
assert.equal(v.reason, 'expr-parse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отвергает без условия', () => {
|
||||||
|
const v = gen.validateAndVerify(Object.assign({}, GOOD, { story: '' }));
|
||||||
|
assert.equal(v.ok, false);
|
||||||
|
assert.equal(v.reason, 'no-story');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сбрасывает мусорный tex шага в пустую строку', () => {
|
||||||
|
const v = gen.validateAndVerify(Object.assign({}, GOOD, { solution: [{ note: 'ok', tex: 'не выражение!!!' }] }));
|
||||||
|
assert.equal(v.ok, true);
|
||||||
|
assert.equal(v.problem.solution[0].tex, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('practiceGenService.generate (LLM застаблен)', () => {
|
||||||
|
const askValid = async () => ({ text: '```json\n' + JSON.stringify(GOOD) + '\n```' });
|
||||||
|
const askWrong = async () => ({ text: JSON.stringify(Object.assign({}, GOOD, { answer: 99 })) });
|
||||||
|
const askOff = async () => ({ text: null, error: 'off' });
|
||||||
|
|
||||||
|
it('валидная задача с первой попытки', async () => {
|
||||||
|
const r = await gen.generate('word-linear', { ask: askValid, maxRetries: 3 });
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.attempts, 1);
|
||||||
|
assert.equal(r.problem.answer, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ретраит и берёт валидную со второй попытки', async () => {
|
||||||
|
let n = 0;
|
||||||
|
const ask = async () => { n++; return n === 1 ? { text: 'мусор без json' } : { text: JSON.stringify(GOOD) }; };
|
||||||
|
const r = await gen.generate('word-linear', { ask, maxRetries: 3 });
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.attempts, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неверный корень N раз → unverified (в пул не попадёт)', async () => {
|
||||||
|
const r = await gen.generate('word-linear', { ask: askWrong, maxRetries: 3 });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.error, 'unverified');
|
||||||
|
assert.equal(r.attempts, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет провайдера → off', async () => {
|
||||||
|
const r = await gen.generate('word-linear', { ask: askOff, maxRetries: 3 });
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.error, 'off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/practice pool endpoints', () => {
|
||||||
|
let teacher, student;
|
||||||
|
before(async () => {
|
||||||
|
teacher = (await getToken('teacher')).token;
|
||||||
|
student = (await getToken('student')).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /generate запрещён ученику (403)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, student);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /generate учителю без провайдера → 503 (off)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, teacher);
|
||||||
|
assert.equal(res.status, 503, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.error, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /generate неизвестная тема → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/generate', { topic: 'nope' }, teacher);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /author учителем (валидная) → в пул', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/author',
|
||||||
|
{ topic: 'word-linear', story: 'Задача от учителя', lhs: '2*x + 1', rhs: '7', answer: 3 }, teacher);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.equal(res.body.problem.answer, 3);
|
||||||
|
assert.equal(res.body.problem.kind, 'word');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /author с неверным корнем → 422 (в пул не попадёт)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/author',
|
||||||
|
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 5 }, teacher);
|
||||||
|
assert.equal(res.status, 422, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /author ученику запрещён (403)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/author',
|
||||||
|
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 3 }, student);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /pool отдаёт одобренные задачи', async () => {
|
||||||
|
db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status)
|
||||||
|
VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run();
|
||||||
|
const res = await inject('GET', '/api/practice/pool?skill=word-linear', null, student);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.ok(Array.isArray(res.body.problems));
|
||||||
|
const p = res.body.problems.find(x => x.skill === 'word-linear');
|
||||||
|
assert.ok(p, 'pool problem present');
|
||||||
|
assert.equal(p.kind, 'word');
|
||||||
|
assert.equal(p.lhsExpr, '3*x + 4');
|
||||||
|
assert.equal(p.answer, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
|
||||||
|
* Covers: auth-only (401); correct создаёт строку; wrong не растит solved, но
|
||||||
|
* растит attempts и обнуляет серию; серия из MASTERY_STREAK → mastered;
|
||||||
|
* прогресс per-user; валидация входа (400).
|
||||||
|
*/
|
||||||
|
const { describe, it, before } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты).
|
||||||
|
app.use('/api/practice', require('../src/routes/practice'));
|
||||||
|
|
||||||
|
const { after } = require('node:test');
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
const SKILL = 'linear-basic';
|
||||||
|
|
||||||
|
describe('/api/practice progress', () => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
token = (await getToken('student')).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /progress requires auth (401)', async () => {
|
||||||
|
const res = await inject('GET', '/api/practice/progress', null, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /attempt requires auth (401)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correct attempt creates a row (solved=1, streak=1)', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.equal(res.body.progress.skill, SKILL);
|
||||||
|
assert.equal(res.body.progress.solved, 1);
|
||||||
|
assert.equal(res.body.progress.attempts, 1);
|
||||||
|
assert.equal(res.body.progress.cur_streak, 1);
|
||||||
|
assert.equal(res.body.progress.best_streak, 1);
|
||||||
|
assert.equal(res.body.progress.mastered, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /progress lists the row', async () => {
|
||||||
|
const res = await inject('GET', '/api/practice/progress', null, token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.ok(Array.isArray(res.body.progress));
|
||||||
|
const row = res.body.progress.find(r => r.skill === SKILL);
|
||||||
|
assert.ok(row, 'skill row present');
|
||||||
|
assert.equal(row.solved, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrong attempt: attempts++, solved unchanged, streak resets to 0', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: false }, token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.progress.solved, 1, 'solved unchanged');
|
||||||
|
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
|
||||||
|
assert.equal(res.body.progress.cur_streak, 0, 'streak reset');
|
||||||
|
assert.equal(res.body.progress.best_streak, 1, 'best streak kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streak of 5 correct → mastered=1 (and stays mastered after a miss)', async () => {
|
||||||
|
const sk = 'mastery-skill';
|
||||||
|
let last;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
last = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
||||||
|
}
|
||||||
|
assert.equal(last.body.progress.cur_streak, 5);
|
||||||
|
assert.equal(last.body.progress.best_streak, 5);
|
||||||
|
assert.equal(last.body.progress.mastered, 1, 'mastered after 5 in a row');
|
||||||
|
|
||||||
|
const miss = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
|
||||||
|
assert.equal(miss.body.progress.cur_streak, 0, 'streak reset on miss');
|
||||||
|
assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SR: box растёт на верный ответ и сбрасывается на ошибку; due отражает срок', async () => {
|
||||||
|
const sk = 'sr-skill';
|
||||||
|
const c1 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
||||||
|
assert.equal(c1.body.progress.box, 1, 'box=1 после первого верного');
|
||||||
|
assert.equal(c1.body.progress.due, 0, 'свежий навык не просрочен (срок в будущем)');
|
||||||
|
const c2 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
||||||
|
assert.equal(c2.body.progress.box, 2, 'box растёт на следующем верном');
|
||||||
|
const w = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
|
||||||
|
assert.equal(w.body.progress.box, 0, 'ошибка сбрасывает box в 0');
|
||||||
|
assert.equal(w.body.progress.due, 1, 'после ошибки навык сразу к повторению (due=1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('progress is per-user (другой ученик начинает с нуля)', async () => {
|
||||||
|
const other = (await getToken('student')).token;
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
|
||||||
|
assert.equal(res.body.progress.solved, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validation: missing skill → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { correct: true }, token);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validation: correct not boolean → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: 'yes' }, token);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validation: skill too long → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/attempt', { skill: 'x'.repeat(200), correct: true }, token);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/practice/class-stats (аналитика класса)', () => {
|
||||||
|
let teacher, other, s1, s2, classId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
teacher = await getToken('teacher');
|
||||||
|
other = await getToken('teacher');
|
||||||
|
s1 = await getToken('student');
|
||||||
|
s2 = await getToken('student');
|
||||||
|
// класс учителя + два ученика в нём
|
||||||
|
const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE');
|
||||||
|
classId = info.lastInsertRowid;
|
||||||
|
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId);
|
||||||
|
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId);
|
||||||
|
// прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic
|
||||||
|
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token);
|
||||||
|
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token);
|
||||||
|
await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('владелец класса видит агрегаты и матрицу', async () => {
|
||||||
|
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.students.length, 2, 'два ученика');
|
||||||
|
assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке');
|
||||||
|
const lb = res.body.perSkill.find(s => s.skill === 'lin-basic');
|
||||||
|
assert.ok(lb, 'агрегат по lin-basic есть');
|
||||||
|
assert.equal(lb.attempted, 2, 'оба пробовали lin-basic');
|
||||||
|
assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('чужой класс → 403', async () => {
|
||||||
|
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученику запрещено (требуется роль) → 403', async () => {
|
||||||
|
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без class_id → 400', async () => {
|
||||||
|
const res = await inject('GET', '/api/practice/class-stats', null, teacher.token);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /assign владельцем → уведомляет всех учеников', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear', title: 'Линейные уравнения' }, teacher.token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.equal(res.body.notified, 2, 'двое учеников уведомлены');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /assign чужой класс → 403', async () => {
|
||||||
|
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear' }, other.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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-search:focus { border-color: var(--violet); }
|
||||||
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
|
.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; }
|
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
/* formula bar */
|
/* formula bar */
|
||||||
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
|
/* 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> Экзамен-модули
|
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
|
||||||
</button>
|
</button>
|
||||||
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
|
<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>
|
||||||
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
<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> Помощник Квантик
|
<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 id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Игры ── -->
|
<!-- ── Модули ── -->
|
||||||
<div class="tab-pane" id="tab-games">
|
<div class="tab-pane" id="tab-games">
|
||||||
<div class="section-title">Управление играми</div>
|
<div class="section-title">Управление модулями</div>
|
||||||
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
||||||
<div class="perm-grid" id="games-features-grid">
|
<div class="perm-grid" id="games-features-grid">
|
||||||
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+134
-4
@@ -109,6 +109,29 @@
|
|||||||
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||||||
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
|
.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 ── */
|
||||||
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
|
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
|
||||||
.student-search-wrap .form-input { width: 100%; }
|
.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 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="members" onclick="switchDetailTab(this)">Ученики</button>
|
||||||
<button class="tab-btn" data-tab="assign" 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="journal" onclick="switchDetailTab(this)">Журнал</button>
|
||||||
<button class="tab-btn" data-tab="announce" 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>
|
<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 class="assign-list" id="d-assignments"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Debts (что висит у учеников) -->
|
||||||
|
<div class="tab-pane" id="dtab-debts">
|
||||||
|
<div id="debts-content"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Journal -->
|
<!-- Journal -->
|
||||||
<div class="tab-pane" id="dtab-journal">
|
<div class="tab-pane" id="dtab-journal">
|
||||||
<div id="journal-content"><div class="spinner"></div></div>
|
<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 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-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-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>
|
||||||
<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">
|
<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">
|
||||||
Вопросы подбираются случайно из базы по выбранному предмету
|
Вопросы подбираются случайно из базы по выбранному предмету
|
||||||
@@ -1005,6 +1034,10 @@
|
|||||||
|
|
||||||
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
|
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
|
||||||
function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; }
|
function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; }
|
||||||
|
// Безопасная подстановка строки в JS-строковый литерал внутри inline-обработчика
|
||||||
|
// (onclick="f('${escJ(name)}')"). esc() не экранирует ' → нужно ещё \-экранировать
|
||||||
|
// обратный слэш и кавычку, иначе имя ученика с ' даёт XSS.
|
||||||
|
function escJ(s) { return esc(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
|
||||||
function toast(msg) {
|
function toast(msg) {
|
||||||
const el = document.getElementById('toast');
|
const el = document.getElementById('toast');
|
||||||
el.textContent = msg; el.classList.add('show');
|
el.textContent = msg; el.classList.add('show');
|
||||||
@@ -1133,7 +1166,7 @@
|
|||||||
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
|
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
|
||||||
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
|
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
|
||||||
<td>${prepToggleHtml(m.id)}</td>
|
<td>${prepToggleHtml(m.id)}</td>
|
||||||
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td>
|
<td><button class="btn-danger" onclick="kickMember(${m.id},'${escJ(m.name)}')">Удалить</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1199,6 +1232,102 @@
|
|||||||
}).join('');
|
}).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 ══ */
|
/* ══ Detail tabs ══ */
|
||||||
function switchDetailTab(btn) {
|
function switchDetailTab(btn) {
|
||||||
const name = btn.dataset.tab;
|
const name = btn.dataset.tab;
|
||||||
@@ -1208,6 +1337,7 @@
|
|||||||
document.getElementById('dtab-' + name).classList.add('active');
|
document.getElementById('dtab-' + name).classList.add('active');
|
||||||
if (name === 'announce') loadAnnouncements();
|
if (name === 'announce') loadAnnouncements();
|
||||||
if (name === 'dash') loadClassDashboard();
|
if (name === 'dash') loadClassDashboard();
|
||||||
|
if (name === 'debts') loadDebts();
|
||||||
if (name === 'journal') loadJournal();
|
if (name === 'journal') loadJournal();
|
||||||
if (name === 'settings') loadSettings();
|
if (name === 'settings') loadSettings();
|
||||||
if (name === 'works') loadClassWorks();
|
if (name === 'works') loadClassWorks();
|
||||||
@@ -1403,7 +1533,7 @@
|
|||||||
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
|
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
|
||||||
} else {
|
} else {
|
||||||
drop.innerHTML = matches.map(s => `
|
drop.innerHTML = matches.map(s => `
|
||||||
<div class="student-opt" onmousedown="selectStudent(${s.id},'${esc(s.name)}','${esc(s.email)}')">
|
<div class="student-opt" onmousedown="selectStudent(${s.id},'${escJ(s.name)}','${escJ(s.email)}')">
|
||||||
<div class="student-opt-name">${esc(s.name)}</div>
|
<div class="student-opt-name">${esc(s.name)}</div>
|
||||||
<div class="student-opt-email">${esc(s.email)}</div>
|
<div class="student-opt-email">${esc(s.email)}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
@@ -1884,7 +2014,7 @@
|
|||||||
const rank = (r, i) => {
|
const rank = (r, i) => {
|
||||||
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
|
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
|
||||||
const pc = pctCls(r.percent);
|
const pc = pctCls(r.percent);
|
||||||
const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${esc(r.name)}',${r.percent??'null'})"` : '';
|
const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${escJ(r.name)}',${r.percent??'null'})"` : '';
|
||||||
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
|
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
|
||||||
<div class="res-rank">${medal}</div>
|
<div class="res-rank">${medal}</div>
|
||||||
<div class="res-name">
|
<div class="res-name">
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
LS.sidebar?.init();
|
LS.sidebar?.init();
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
const feats = await LS.loadFeatures();
|
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?.();
|
LS.hideDisabledFeatures?.();
|
||||||
await loadCollection();
|
await loadCollection();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+55
-8
@@ -181,22 +181,42 @@
|
|||||||
background: var(--fn-color, var(--violet));
|
background: var(--fn-color, var(--violet));
|
||||||
box-shadow: 0 0 6px var(--fn-color, var(--violet));
|
box-shadow: 0 0 6px var(--fn-color, var(--violet));
|
||||||
}
|
}
|
||||||
|
.fn-field { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }
|
||||||
.fn-input {
|
.fn-input {
|
||||||
flex: 1; border: none; outline: none; background: transparent;
|
width: 100%; border: none; outline: none; background: transparent;
|
||||||
font-family: 'Manrope', monospace; font-size: 0.88rem; font-weight: 600;
|
font-family: 'Manrope', monospace; font-size: 0.88rem; font-weight: 600;
|
||||||
color: var(--text); padding: 0; min-width: 0;
|
color: var(--text); padding: 0; min-width: 0;
|
||||||
}
|
}
|
||||||
.fn-input::placeholder { color: var(--text-3); font-weight: 500; }
|
.fn-input::placeholder { color: var(--text-3); font-weight: 500; }
|
||||||
|
|
||||||
/* KaTeX live preview */
|
/* введённая функция как KaTeX прямо в строке; клик — правка текста на месте */
|
||||||
|
.fn-math { display: none; width: 100%; cursor: text; overflow-x: auto; overflow-y: hidden; min-height: 19px; line-height: 1.3; }
|
||||||
|
.fn-math .katex { color: var(--fn-color, rgba(255,255,255,.9)); font-size: 1.12em; }
|
||||||
|
.fn-field.has-math .fn-input { display: none; }
|
||||||
|
.fn-field.has-math .fn-math { display: block; }
|
||||||
|
|
||||||
|
/* живое превью формулы — только пока строка в режиме правки (поле в фокусе) */
|
||||||
.fn-preview {
|
.fn-preview {
|
||||||
min-height: 20px; padding: 3px 4px 3px 36px;
|
min-height: 0; padding: 4px 8px 2px 36px; margin-top: 2px;
|
||||||
font-size: 0.82rem; line-height: 1.5;
|
font-size: 0.8rem; line-height: 1.4;
|
||||||
color: rgba(255,255,255,.65);
|
overflow-x: auto; overflow-y: hidden; display: none;
|
||||||
overflow: hidden; display: none;
|
|
||||||
}
|
}
|
||||||
.fn-preview.has-content { display: block; }
|
.fn-row:focus-within + .fn-preview.has-content { display: block; }
|
||||||
.fn-preview .katex { color: rgba(255,255,255,.8); font-size: 1em; }
|
.fn-preview .katex { color: var(--fn-color, rgba(255,255,255,.6)); font-size: 1.05em; opacity: .8; }
|
||||||
|
|
||||||
|
/* keypad вставки структур (как редактор формул) */
|
||||||
|
.gp-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; margin-bottom: 6px; }
|
||||||
|
.kp-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center; min-height: 32px;
|
||||||
|
padding: 5px 2px; border-radius: 9px; border: 1.5px solid var(--border-h);
|
||||||
|
background: rgba(255,255,255,.02); color: var(--text-2);
|
||||||
|
font-family: 'Manrope', monospace; font-size: 0.78rem; font-weight: 700;
|
||||||
|
cursor: pointer; transition: all .14s;
|
||||||
|
}
|
||||||
|
.kp-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.08); transform: translateY(-1px); }
|
||||||
|
.kp-btn:active { transform: translateY(0); }
|
||||||
|
.kp-btn .katex { font-size: 1em; }
|
||||||
|
.preset-btn .katex { font-size: 0.96em; }
|
||||||
.fn-err {
|
.fn-err {
|
||||||
font-size: 0.68rem; color: var(--pink); font-weight: 600;
|
font-size: 0.68rem; color: var(--pink); font-weight: 600;
|
||||||
padding: 2px 0 0 22px; display: none;
|
padding: 2px 0 0 22px; display: none;
|
||||||
@@ -439,6 +459,33 @@
|
|||||||
}
|
}
|
||||||
#graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
#graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* плавающие контролы вида поверх canvas */
|
||||||
|
.graph-view-ctrls {
|
||||||
|
position: absolute; top: 12px; right: 12px; z-index: 4;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.gv-btn {
|
||||||
|
width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 10px; border: 1px solid rgba(255,255,255,.12);
|
||||||
|
background: rgba(13,13,26,.62); color: rgba(255,255,255,.72);
|
||||||
|
cursor: pointer; transition: all .14s; backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.gv-btn svg { width: 17px; height: 17px; }
|
||||||
|
.gv-btn:hover { border-color: var(--violet); color: #fff; background: rgba(155,93,229,.32); }
|
||||||
|
.gv-btn.active { border-color: var(--violet); color: #fff; background: var(--violet); box-shadow: 0 0 12px rgba(155,93,229,.5); }
|
||||||
|
|
||||||
|
/* кнопки управления функцией (глаз/очистить) в строке */
|
||||||
|
.fn-act {
|
||||||
|
flex-shrink: 0; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
||||||
|
border: none; background: transparent; color: var(--text-3);
|
||||||
|
border-radius: 8px; cursor: pointer; transition: all .14s; padding: 0;
|
||||||
|
}
|
||||||
|
.fn-act svg { width: 15px; height: 15px; }
|
||||||
|
.fn-act:hover { color: var(--fn-color, var(--violet)); background: rgba(155,93,229,.1); }
|
||||||
|
.fn-act.off { color: var(--text-3); opacity: .85; }
|
||||||
|
.fn-row.fn-hidden { opacity: .5; }
|
||||||
|
.fn-row.fn-hidden .fn-math, .fn-row.fn-hidden .fn-input { text-decoration: line-through; text-decoration-color: rgba(255,255,255,.3); }
|
||||||
|
|
||||||
/* info bar */
|
/* info bar */
|
||||||
.graph-info-bar {
|
.graph-info-bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
+34
-16
@@ -408,6 +408,13 @@ body {
|
|||||||
background: var(--grad-1);
|
background: var(--grad-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Админ-инструмент (конструктор задач) — янтарное выделение, как кнопка в тренажёре */
|
||||||
|
.sb-link.sb-admin-tool { color: #b45309; background: rgba(245,158,11,0.10); }
|
||||||
|
.sb-link.sb-admin-tool .sb-icon { color: #f59e0b; }
|
||||||
|
.sb-link.sb-admin-tool:hover { color: #92400e; background: rgba(245,158,11,0.18); }
|
||||||
|
.sb-link.sb-admin-tool.active { color: #b45309; background: linear-gradient(135deg, rgba(245,158,11,0.20), rgba(249,115,22,0.16)); font-weight: 700; }
|
||||||
|
.sb-link.sb-admin-tool.active::before { background: linear-gradient(180deg, #f59e0b, #f97316); }
|
||||||
|
|
||||||
.sb-icon {
|
.sb-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -1039,7 +1046,7 @@ body {
|
|||||||
body.no-class #lb-section { display: none !important; }
|
body.no-class #lb-section { display: none !important; }
|
||||||
|
|
||||||
/* Gamification kill-switch.
|
/* 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 /
|
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
|
||||||
achievement / frame element must vanish — across the whole app,
|
achievement / frame element must vanish — across the whole app,
|
||||||
not just the dashboard. The rules below cover:
|
not just the dashboard. The rules below cover:
|
||||||
@@ -1050,21 +1057,32 @@ body.no-class #lb-section { display: none !important; }
|
|||||||
• a catch-all [data-gamified] hook that wraps any future block —
|
• a catch-all [data-gamified] hook that wraps any future block —
|
||||||
authors of new pages should wrap XP UI in a <div data-gamified>
|
authors of new pages should wrap XP UI in a <div data-gamified>
|
||||||
instead of inventing new classes. */
|
instead of inventing new classes. */
|
||||||
body.no-gamification .gam-bar,
|
.no-gamification .gam-bar,
|
||||||
body.no-gamification .lb-widget,
|
.no-gamification .lb-widget,
|
||||||
body.no-gamification .achievements-section,
|
.no-gamification .achievements-section,
|
||||||
body.no-gamification #tab-btn-achievements,
|
.no-gamification #tab-btn-achievements,
|
||||||
body.no-gamification #tab-btn-shop,
|
.no-gamification #tab-btn-shop,
|
||||||
body.no-gamification #tab-achievements,
|
.no-gamification #tab-achievements,
|
||||||
body.no-gamification #tab-shop,
|
.no-gamification #tab-shop,
|
||||||
body.no-gamification #frames-section,
|
.no-gamification #frames-section,
|
||||||
body.no-gamification .hero-xp-badge,
|
.no-gamification .hero-xp-badge,
|
||||||
body.no-gamification .po-xp,
|
.no-gamification .po-xp,
|
||||||
body.no-gamification .xp-card,
|
.no-gamification .xp-card,
|
||||||
body.no-gamification .xp-bar,
|
.no-gamification .xp-bar,
|
||||||
body.no-gamification .xp-pill,
|
.no-gamification .xp-pill,
|
||||||
body.no-gamification .xp-badge,
|
.no-gamification .xp-badge,
|
||||||
body.no-gamification [data-gamified] { display: none !important; }
|
/* 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)
|
RESPONSIVE — SMALL PHONES (≤ 480px)
|
||||||
|
|||||||
+148
-24
@@ -81,7 +81,33 @@
|
|||||||
}
|
}
|
||||||
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
||||||
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
/* ── 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 {
|
.hero-card {
|
||||||
position: relative; border-radius: 18px; padding: 18px 20px;
|
position: relative; border-radius: 18px; padding: 18px 20px;
|
||||||
display: flex; flex-direction: column; min-height: 196px;
|
display: flex; flex-direction: column; min-height: 196px;
|
||||||
@@ -1532,6 +1558,13 @@
|
|||||||
|
|
||||||
<div class="container">
|
<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) -->
|
<!-- Gamification Bar (students only) -->
|
||||||
<div class="gam-bar" id="gam-bar" style="display:none">
|
<div class="gam-bar" id="gam-bar" style="display:none">
|
||||||
<div class="gam-level">
|
<div class="gam-level">
|
||||||
@@ -1750,6 +1783,11 @@
|
|||||||
<div class="widget" id="w-tests">
|
<div class="widget" id="w-tests">
|
||||||
<div class="w-head"><div class="w-title">Тесты</div></div>
|
<div class="w-head"><div class="w-title">Тесты</div></div>
|
||||||
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
|
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
|
||||||
|
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
|
||||||
|
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
|
||||||
|
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
|
||||||
|
<div class="subj-mini-grid" id="available-tests-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Col 3: Progress -->
|
<!-- Col 3: Progress -->
|
||||||
@@ -1884,6 +1922,7 @@
|
|||||||
<!-- Join modal -->
|
<!-- Join modal -->
|
||||||
<!-- Quick-start test modal -->
|
<!-- Quick-start test modal -->
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/assignment-utils.js"></script>
|
||||||
<script src="/js/sound.js"></script>
|
<script src="/js/sound.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/notifications.js"></script>
|
<script src="/js/notifications.js"></script>
|
||||||
@@ -2222,10 +2261,14 @@
|
|||||||
async function loadSubjects() {
|
async function loadSubjects() {
|
||||||
const list = document.getElementById('subjects-list');
|
const list = document.getElementById('subjects-list');
|
||||||
try {
|
try {
|
||||||
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' };
|
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
|
||||||
const subjects = await LS.getSubjects();
|
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
|
||||||
|
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) => {
|
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 count = s.default_count || 25;
|
||||||
const testId = s.default_test_id || null;
|
const testId = s.default_test_id || null;
|
||||||
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
|
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
|
||||||
@@ -2253,6 +2296,32 @@
|
|||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
|
||||||
|
async function loadAvailableTests() {
|
||||||
|
const wrap = document.getElementById('avail-tests-wrap');
|
||||||
|
const list = document.getElementById('available-tests-list');
|
||||||
|
if (!wrap || !list) return;
|
||||||
|
try {
|
||||||
|
const tests = await LS.getTests();
|
||||||
|
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
|
||||||
|
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||||||
|
list.innerHTML = tests.map((t, i) => {
|
||||||
|
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
|
||||||
|
const iconName = ICONS[t.subject_slug] || 'book-open';
|
||||||
|
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
|
||||||
|
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
|
||||||
|
<div class="smc-body">
|
||||||
|
<div class="smc-name">${esc(t.title)}</div>
|
||||||
|
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-right" class="smc-arrow"></i>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
wrap.style.display = '';
|
||||||
|
reIcons();
|
||||||
|
} catch { wrap.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
|
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
|
||||||
async function loadAssignments() {
|
async function loadAssignments() {
|
||||||
try {
|
try {
|
||||||
@@ -2346,15 +2415,8 @@
|
|||||||
body.classList.toggle('collapsed');
|
body.classList.toggle('collapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Urgency sort score (lower = shown first) ── */
|
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
|
||||||
function urgencyScore(a) {
|
function urgencyScore(a) { return AssignmentUtils.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
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Is assignment urgent for teacher (within 48h) ── */
|
/* ── Is assignment urgent for teacher (within 48h) ── */
|
||||||
function isTeacherUrgent(a) {
|
function isTeacherUrgent(a) {
|
||||||
@@ -2422,7 +2484,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Upload-only homework (no test, no file) ── */
|
/* ── 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 over = a.deadline && new Date(a.deadline) < new Date();
|
||||||
const sub = _mySubmissions.get(a.id);
|
const sub = _mySubmissions.get(a.id);
|
||||||
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
||||||
@@ -2659,18 +2721,22 @@
|
|||||||
reIcons(); return;
|
reIcons(); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify
|
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
|
||||||
function classify(a) {
|
function classify(a) {
|
||||||
const maxAtt = a.max_attempts || 0;
|
const t = AssignmentUtils.type(a);
|
||||||
const usedAtt = a.attempts_used ?? 0;
|
if (t === 'textbook') {
|
||||||
if (a.textbook_id) {
|
if (AssignmentUtils.isDone(a)) return 'done';
|
||||||
if (a.completed_at || a.textbook_all_read) return 'done';
|
|
||||||
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
|
if (t === 'test') {
|
||||||
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
|
if (AssignmentUtils.isDone(a)) return 'done';
|
||||||
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
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';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3670,12 +3736,36 @@
|
|||||||
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
|
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) ══════════════════════ */
|
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
|
||||||
function loadLastResultsWidget(rows) {
|
function loadLastResultsWidget(rows) {
|
||||||
const w = document.getElementById('w-last-results');
|
const w = document.getElementById('w-last-results');
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
|
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 = '';
|
w.style.display = '';
|
||||||
document.getElementById('last-results-list').innerHTML = completed.map(h => {
|
document.getElementById('last-results-list').innerHTML = completed.map(h => {
|
||||||
const pct = Math.round(h.score / h.total * 100);
|
const pct = Math.round(h.score / h.total * 100);
|
||||||
@@ -3689,6 +3779,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
syncProgressCol();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
|
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
|
||||||
@@ -3702,7 +3793,7 @@
|
|||||||
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
|
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
|
||||||
});
|
});
|
||||||
const entries = Object.entries(bySubj);
|
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 = '';
|
w.style.display = '';
|
||||||
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
|
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);
|
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>
|
<span class="sp-pct" style="color:${color}">${avg}%</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
syncProgressCol();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
|
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
|
||||||
@@ -4285,6 +4377,7 @@
|
|||||||
loadLabOfDay();
|
loadLabOfDay();
|
||||||
loadPetHero();
|
loadPetHero();
|
||||||
loadFlashcardWidget();
|
loadFlashcardWidget();
|
||||||
|
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
||||||
@@ -4298,6 +4391,7 @@
|
|||||||
renderFlashcardWidget(r);
|
renderFlashcardWidget(r);
|
||||||
w.style.display = '';
|
w.style.display = '';
|
||||||
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
||||||
|
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFlashcardWidget(r) {
|
function renderFlashcardWidget(r) {
|
||||||
@@ -4473,6 +4567,7 @@
|
|||||||
} else {
|
} else {
|
||||||
// Student: full layout
|
// Student: full layout
|
||||||
loadSubjects();
|
loadSubjects();
|
||||||
|
loadAvailableTests();
|
||||||
loadAssignments();
|
loadAssignments();
|
||||||
loadStats();
|
loadStats();
|
||||||
loadGamification();
|
loadGamification();
|
||||||
@@ -4481,13 +4576,42 @@
|
|||||||
loadDashboardStats();
|
loadDashboardStats();
|
||||||
applyDashboardPrefs();
|
applyDashboardPrefs();
|
||||||
}
|
}
|
||||||
|
loadLiveLesson();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
|
||||||
LS.notif.init();
|
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)
|
// Real-time SSE for page-specific events (notif handled by notifications.js)
|
||||||
LS.connectSSE(ev => {
|
LS.connectSSE(ev => {
|
||||||
if (ev.type === 'assignment') {
|
if (ev.type === 'assignment') {
|
||||||
LS.toast(ev.message, 'info');
|
LS.toast(ev.message, 'info');
|
||||||
isTeacher ? loadAdminAssignments() : loadAssignments();
|
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') {
|
} else if (ev.type === 'session') {
|
||||||
LS.toast(ev.message, 'info');
|
LS.toast(ev.message, 'info');
|
||||||
if (isTeacher) loadAdminSessions();
|
if (isTeacher) loadAdminSessions();
|
||||||
|
|||||||
@@ -447,7 +447,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="fc-stats" id="fc-stats-bar"></div>
|
<div class="fc-stats" id="fc-stats-bar"></div>
|
||||||
<div class="deck-grid" id="deck-grid">
|
<div class="deck-grid" id="deck-grid">
|
||||||
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-3)">Загрузка…</div>
|
<div style="grid-column:1/-1"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -694,6 +694,7 @@ let _curDeckReadonly = false; // общая колода (не вла
|
|||||||
let _shareData = { shares: [], classes: [], students: [] };
|
let _shareData = { shares: [], classes: [], students: [] };
|
||||||
let _shareTab = 'class';
|
let _shareTab = 'class';
|
||||||
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
|
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
|
||||||
|
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций (объявлено ДО init, иначе TDZ)
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
/* ── auth ── */
|
/* ── auth ── */
|
||||||
@@ -747,8 +748,9 @@ function bindStudyKeys() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций
|
|
||||||
async function loadDecks() {
|
async function loadDecks() {
|
||||||
|
const _grid = document.getElementById('deck-grid');
|
||||||
|
if (_grid && LS.skeleton) _grid.innerHTML = LS.skeleton(6, 'card'); // системные skeleton-карточки вместо «Загрузка…»
|
||||||
const [decks, stats, tracks] = await Promise.all([
|
const [decks, stats, tracks] = await Promise.all([
|
||||||
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
||||||
LS.api('/api/flashcards/stats').catch(()=>null),
|
LS.api('/api/flashcards/stats').catch(()=>null),
|
||||||
|
|||||||
+228
-22
@@ -125,6 +125,41 @@
|
|||||||
/* student name in teacher view */
|
/* student name in teacher view */
|
||||||
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.container { padding: 16px 14px 80px; }
|
.container { padding: 16px 14px 80px; }
|
||||||
.hw-top { gap: 8px; }
|
.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-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
|
||||||
.hw-card-actions { flex-wrap: wrap; }
|
.hw-card-actions { flex-wrap: wrap; }
|
||||||
.hw-upload-area { padding: 20px 16px; }
|
.hw-upload-area { padding: 20px 16px; }
|
||||||
|
.hw-acard { flex-wrap: wrap; }
|
||||||
|
.hw-acard-right { width: 100%; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.container { padding: 12px 10px 80px; }
|
.container { padding: 12px 10px 80px; }
|
||||||
@@ -152,8 +189,15 @@
|
|||||||
<div class="page-title">Домашние задания</div>
|
<div class="page-title">Домашние задания</div>
|
||||||
<div class="page-sub" id="hw-sub">Загрузка…</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 -->
|
<!-- Student: upload area -->
|
||||||
<div id="hw-upload-wrap" style="display:none">
|
<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-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-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
|
||||||
<div class="hw-upload-text">Загрузить работу</div>
|
<div class="hw-upload-text">Загрузить работу</div>
|
||||||
@@ -195,6 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Student: status filters -->
|
<!-- 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-top" id="hw-top-student" style="display:none">
|
||||||
<div class="hw-status-filters">
|
<div class="hw-status-filters">
|
||||||
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
|
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
|
||||||
@@ -213,6 +258,7 @@
|
|||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/notifications.js"></script>
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/assignment-utils.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { user, isTeacher, isAdmin } = LS.initPage();
|
const { user, isTeacher, isAdmin } = LS.initPage();
|
||||||
if (!user) throw new Error('Not logged in');
|
if (!user) throw new Error('Not logged in');
|
||||||
@@ -247,6 +293,14 @@
|
|||||||
resubmitted: 'Повторно', accepted: 'Принято'
|
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 ── */
|
/* ── filter ── */
|
||||||
function filterStatus(st, btn) {
|
function filterStatus(st, btn) {
|
||||||
_statusFilter = st;
|
_statusFilter = st;
|
||||||
@@ -257,33 +311,31 @@
|
|||||||
|
|
||||||
/* ── STUDENT VIEW ── */
|
/* ── STUDENT VIEW ── */
|
||||||
async function initStudent() {
|
async function initStudent() {
|
||||||
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
|
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
|
||||||
document.getElementById('hw-top-student').style.display = '';
|
document.getElementById('hw-top-student').style.display = '';
|
||||||
|
document.getElementById('hw-mysubs-title').style.display = '';
|
||||||
|
|
||||||
// Find student's class
|
// Find student's class (нужен для загрузки работ без привязки к заданию)
|
||||||
try {
|
try {
|
||||||
const classes = await LS.myClasses();
|
const classes = await LS.myClasses();
|
||||||
if (classes.length) {
|
if (classes.length) {
|
||||||
_studentClassId = classes[0].id;
|
_studentClassId = classes[0].id;
|
||||||
document.getElementById('hw-upload-wrap').style.display = '';
|
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 {}
|
} catch {}
|
||||||
|
|
||||||
// Load submissions
|
// Грузим актуальные задания (все классы) + сдачи параллельно
|
||||||
try {
|
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();
|
renderSubmissions();
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
|
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
|
||||||
@@ -312,14 +364,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitHomework() {
|
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');
|
const btn = document.getElementById('hw-submit-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', _selectedFile);
|
fd.append('file', _selectedFile);
|
||||||
fd.append('class_id', _studentClassId);
|
fd.append('class_id', classId);
|
||||||
const assignId = document.getElementById('hw-assignment-sel').value;
|
|
||||||
if (assignId) fd.append('assignment_id', assignId);
|
if (assignId) fd.append('assignment_id', assignId);
|
||||||
const msg = document.getElementById('hw-message').value.trim();
|
const msg = document.getElementById('hw-message').value.trim();
|
||||||
if (msg) fd.append('message', msg);
|
if (msg) fd.append('message', msg);
|
||||||
@@ -336,12 +396,20 @@
|
|||||||
|
|
||||||
// Reload
|
// Reload
|
||||||
_submissions = await LS.getMySubmissions();
|
_submissions = await LS.getMySubmissions();
|
||||||
renderSubmissions();
|
syncStudentLists();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LS.toast(e.message || 'Ошибка отправки', 'error');
|
LS.toast(e.message || 'Ошибка отправки', 'error');
|
||||||
} finally { btn.disabled = !_selectedFile; }
|
} 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) {
|
async function resubmitHomework(subId) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
@@ -355,7 +423,7 @@
|
|||||||
await LS.resubmitWork(subId, fd);
|
await LS.resubmitWork(subId, fd);
|
||||||
LS.toast('Работа отправлена повторно!', 'success');
|
LS.toast('Работа отправлена повторно!', 'success');
|
||||||
_submissions = await LS.getMySubmissions();
|
_submissions = await LS.getMySubmissions();
|
||||||
renderSubmissions();
|
syncStudentLists();
|
||||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -366,7 +434,7 @@
|
|||||||
try {
|
try {
|
||||||
await LS.deleteSubmission(id);
|
await LS.deleteSubmission(id);
|
||||||
_submissions = _submissions.filter(s => s.id !== id);
|
_submissions = _submissions.filter(s => s.id !== id);
|
||||||
renderSubmissions();
|
syncStudentLists();
|
||||||
LS.toast('Удалено', 'info');
|
LS.toast('Удалено', 'info');
|
||||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
}
|
}
|
||||||
@@ -381,6 +449,144 @@
|
|||||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
} 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 ── */
|
/* ── TEACHER VIEW ── */
|
||||||
async function initTeacher() {
|
async function initTeacher() {
|
||||||
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
|
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
|
||||||
|
|||||||
@@ -284,9 +284,15 @@
|
|||||||
el.innerHTML = rows.map(r => {
|
el.innerHTML = rows.map(r => {
|
||||||
const dt = new Date(r.created_at);
|
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'});
|
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">
|
<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>
|
<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>` : ''}
|
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -311,7 +311,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bulk(allow) {
|
async function bulk(allow) {
|
||||||
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
|
if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||||
const classes = _targets.classes || [];
|
const classes = _targets.classes || [];
|
||||||
try {
|
try {
|
||||||
await Promise.all(classes.map(c =>
|
await Promise.all(classes.map(c =>
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
|
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
|
||||||
const srcId = Number(sel.value);
|
const srcId = Number(sel.value);
|
||||||
const srcName = sel.options[sel.selectedIndex].text;
|
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 {
|
try {
|
||||||
const src = await LS.accessClassOpen(srcId);
|
const src = await LS.accessClassOpen(srcId);
|
||||||
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
|
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function classBulk(allow) {
|
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)]]));
|
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||||||
try {
|
try {
|
||||||
await Promise.all(all.map(([type, ref]) =>
|
await Promise.all(all.map(([type, ref]) =>
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
const classes = _matrix.classes || [];
|
const classes = _matrix.classes || [];
|
||||||
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
|
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
|
||||||
const open = !allOpen;
|
const open = !allOpen;
|
||||||
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
|
if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||||
try {
|
try {
|
||||||
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
|
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));
|
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 allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
|
||||||
const open = !allOpen;
|
const open = !allOpen;
|
||||||
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
|
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 {
|
try {
|
||||||
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
|
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));
|
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
|
||||||
|
|||||||
@@ -65,10 +65,11 @@
|
|||||||
|
|
||||||
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
||||||
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
|
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
|
||||||
|
var health = cfg.health || {};
|
||||||
|
|
||||||
// ── Баннер failover ──
|
// ── Баннер failover ──
|
||||||
if (cfg.failover) {
|
if (cfg.failover) {
|
||||||
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' };
|
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка', health: 'не прошёл авто-проверку' };
|
||||||
var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {}
|
var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {}
|
||||||
var ban = document.createElement('div');
|
var ban = document.createElement('div');
|
||||||
ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px';
|
ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px';
|
||||||
@@ -108,6 +109,20 @@
|
|||||||
'</div>';
|
'</div>';
|
||||||
host.appendChild(pc);
|
host.appendChild(pc);
|
||||||
|
|
||||||
|
// ── Сканер бесплатных моделей шлюза Kilo ──
|
||||||
|
var sk = document.createElement('div');
|
||||||
|
sk.className = 'perm-card'; sk.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
|
||||||
|
sk.innerHTML =
|
||||||
|
'<div class="perm-label"><i data-lucide="radar" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Каталог бесплатных моделей Kilo</div>' +
|
||||||
|
'<div class="perm-desc">Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' +
|
||||||
|
(cfg.kiloModelsCustom ? '<b>Сейчас: обновлён сканированием.</b>' : 'Сейчас: встроенный список.') + '</div>' +
|
||||||
|
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
|
||||||
|
'<button id="asst-scan" class="asst-ib primary" style="padding:8px 14px;display:inline-flex;align-items:center;gap:6px">' + SPARK + 'Сканировать модели</button>' +
|
||||||
|
(cfg.kiloModelsCustom ? '<button id="asst-scan-reset" class="asst-ib">Вернуть встроенный список</button>' : '') +
|
||||||
|
'<span id="asst-scan-st" style="font-size:.78rem;color:#8a94a6"></span></div>' +
|
||||||
|
'<div id="asst-scan-res"></div>';
|
||||||
|
host.appendChild(sk);
|
||||||
|
|
||||||
// ── Настройки/статистика ──
|
// ── Настройки/статистика ──
|
||||||
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
|
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
|
||||||
var sc = document.createElement('div');
|
var sc = document.createElement('div');
|
||||||
@@ -117,11 +132,28 @@
|
|||||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
|
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
|
||||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
|
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
|
||||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
|
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
|
||||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
|
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-socratic" ' + (cfg.socratic ? 'checked' : '') + '> Сократический режим: не решать задачи за ученика (теорию объясняет, задачи — наводит)</label>' +
|
||||||
|
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-health" ' + (cfg.healthEnabled !== false ? 'checked' : '') + '> Авто-проверка провайдеров (каждые 15 мин): упавший активный автоматически уступает место здоровому</label>' +
|
||||||
|
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span><button id="asst-healthrun" class="asst-ib">Проверить провайдеров сейчас</button></div>' +
|
||||||
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
|
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
|
||||||
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
|
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
|
||||||
host.appendChild(sc);
|
host.appendChild(sc);
|
||||||
|
|
||||||
|
// ── Знания о системе (индексация модулей/флагов + описание) ──
|
||||||
|
var skb = document.createElement('div');
|
||||||
|
skb.className = 'perm-card'; skb.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
|
||||||
|
var _skAt = cfg.systemKbAt ? (function () { try { return new Date(cfg.systemKbAt).toLocaleString('ru'); } catch (e) { return ''; } })() : '';
|
||||||
|
var _skInfo = cfg.systemKbCount ? (cfg.systemKbCount + ' фрагментов' + (_skAt ? ' · ' + _skAt : '')) : 'ещё не индексировалось';
|
||||||
|
skb.innerHTML =
|
||||||
|
'<div class="perm-label"><i data-lucide="boxes" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Знания о системе для Квантика</div>' +
|
||||||
|
'<div class="perm-desc">Снимок включённых модулей + каталог разделов + ваше описание индексируются, чтобы Квантик знал актуальное состояние платформы и не предлагал отключённое. Запускайте после смены фича-флагов.</div>' +
|
||||||
|
'<textarea id="asst-sysdoc" rows="5" placeholder="Опишите модули/правила платформы своими словами (необязательно) — это тоже попадёт в знания Квантика…" style="' + IN + ';resize:vertical">' + esc(cfg.systemDoc || '') + '</textarea>' +
|
||||||
|
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-index-sys" class="asst-ib primary">Сохранить и проиндексировать систему</button><span id="asst-sysidx-st" style="font-size:.78rem;color:#8a94a6">' + esc(_skInfo) + '</span></div>' +
|
||||||
|
((cfg.systemUndoc && cfg.systemUndoc.length)
|
||||||
|
? '<div style="font-size:.76rem;color:#b45309;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:8px;padding:6px 10px">Без описания (только статус вкл/выкл): <b>' + cfg.systemUndoc.map(esc).join(', ') + '</b>. Опишите их в поле выше, чтобы Квантик отвечал по ним подробно.</div>'
|
||||||
|
: '');
|
||||||
|
host.appendChild(skb);
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
var Q = function (s) { return host.querySelector(s); };
|
var Q = function (s) { return host.querySelector(s); };
|
||||||
|
|
||||||
@@ -143,11 +175,13 @@
|
|||||||
var lim = L
|
var lim = L
|
||||||
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
|
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
|
||||||
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
|
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
|
||||||
|
var h = health[p.id];
|
||||||
|
var hdot = h ? '<span title="' + esc((h.ok ? 'отвечает' : (h.error || 'не отвечает')) + (h.at ? ' · ' + (function () { try { return new Date(h.at).toLocaleString('ru'); } catch (e) { return ''; } })() : '')) + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + (h.ok ? '#059652' : '#e0335e') + ';margin-left:2px;align-self:center"></span>' : '';
|
||||||
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
|
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
|
||||||
'<div class="asst-pcic">' + SPARK + '</div>' +
|
'<div class="asst-pcic">' + SPARK + '</div>' +
|
||||||
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') +
|
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + hdot +
|
||||||
(act ? '<span class="asst-bdg act">активен</span>' : '') +
|
(act ? '<span class="asst-bdg act">активен</span>' : '') +
|
||||||
'<span class="asst-bdg ' + (p.hasKey ? 'key' : 'nokey') + '">' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '</span></div>' +
|
(p.hasKey ? '<span class="asst-bdg key">ключ есть</span>' : p.noKey ? '<span class="asst-bdg key">без ключа</span>' : '<span class="asst-bdg nokey">нет ключа</span>') + '</div>' +
|
||||||
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
|
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
|
||||||
'<div class="asst-pca">' +
|
'<div class="asst-pca">' +
|
||||||
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
|
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
|
||||||
@@ -249,11 +283,99 @@
|
|||||||
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||||
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
|
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
|
||||||
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||||
|
Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||||
|
Q('#asst-health').addEventListener('change', function () { LS.adminSaveAssistant({ healthEnabled: Q('#asst-health').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||||
|
Q('#asst-healthrun').addEventListener('click', async function () {
|
||||||
|
var btn = Q('#asst-healthrun'); btn.disabled = true; btn.textContent = 'Проверяю…';
|
||||||
|
try { await LS.adminAssistantHealth(); LS.toast('Проверка завершена', 'success'); render(); }
|
||||||
|
catch (e) { LS.toast('Ошибка проверки', 'error'); btn.disabled = false; btn.textContent = 'Проверить провайдеров сейчас'; }
|
||||||
|
});
|
||||||
Q('#asst-reindex').addEventListener('click', async function () {
|
Q('#asst-reindex').addEventListener('click', async function () {
|
||||||
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
|
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
|
||||||
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
|
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
|
||||||
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
||||||
});
|
});
|
||||||
|
Q('#asst-index-sys').addEventListener('click', async function () {
|
||||||
|
var btn = Q('#asst-index-sys'), st = Q('#asst-sysidx-st'), old = btn.textContent; btn.disabled = true; btn.textContent = 'Индексирую…';
|
||||||
|
try {
|
||||||
|
await LS.adminSaveAssistant({ systemDoc: Q('#asst-sysdoc').value });
|
||||||
|
var r = await LS.adminAssistantIndexSystem();
|
||||||
|
st.textContent = ((r && r.count) || 0) + ' фрагментов · только что';
|
||||||
|
LS.toast('Система проиндексирована', 'success');
|
||||||
|
} catch (e) { LS.toast('Ошибка индексации', 'error'); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = old; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Сканер моделей ──
|
||||||
|
var scanProvId = null;
|
||||||
|
function applyScan() {
|
||||||
|
var trs = Q('#asst-scan-res').querySelectorAll('tbody tr[data-mid]');
|
||||||
|
var models = [];
|
||||||
|
trs.forEach(function (tr) {
|
||||||
|
var cb = tr.querySelector('.asst-scan-cb'); if (!cb || !cb.checked) return;
|
||||||
|
var id = tr.getAttribute('data-mid');
|
||||||
|
var ctx = Number(tr.getAttribute('data-ctx')) || null, out = Number(tr.getAttribute('data-out')) || null;
|
||||||
|
var ex = (cfg.kiloModels || []).find(function (x) { return x.id === id; });
|
||||||
|
var label = ex ? ex.label : (id.split('/').pop().replace(/:free$/, '') + (ctx ? ' (' + fmtTok(ctx) + ')' : ''));
|
||||||
|
models.push({ id: id, label: label, ctx: ctx, out: out });
|
||||||
|
});
|
||||||
|
if (!models.length) { LS.toast('Отметьте хотя бы одну модель', 'warn'); return; }
|
||||||
|
LS.adminAssistantApplyModels(models).then(function () { LS.toast('Список моделей обновлён (' + models.length + ')', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
|
||||||
|
}
|
||||||
|
async function runScan() {
|
||||||
|
var st = Q('#asst-scan-st'), res = Q('#asst-scan-res'), btn = Q('#asst-scan');
|
||||||
|
btn.disabled = true; st.textContent = 'Сканирую шлюз…'; res.innerHTML = '';
|
||||||
|
var r; try { r = await LS.adminAssistantScan(); } catch (e) { st.textContent = 'Ошибка запроса'; btn.disabled = false; return; }
|
||||||
|
if (!r || r.error) { st.textContent = 'Ошибка: ' + esc((r && r.error) || '—'); btn.disabled = false; return; }
|
||||||
|
scanProvId = r.providerId;
|
||||||
|
st.textContent = 'Найдено бесплатных: ' + r.models.length + ' (провайдер «' + esc(r.providerName) + '»). Тестирую русский…';
|
||||||
|
var rows = r.models.map(function (m) {
|
||||||
|
return '<tr data-mid="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '">' +
|
||||||
|
'<td style="padding:5px 6px"><input type="checkbox" class="asst-scan-cb"' + (m.status === 'current' ? ' checked' : '') + '></td>' +
|
||||||
|
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem">' + esc(m.id) + '</td>' +
|
||||||
|
'<td style="padding:5px 6px;white-space:nowrap;color:#8a94a6">' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '</td>' +
|
||||||
|
'<td style="padding:5px 6px"><span class="asst-bdg ' + (m.status === 'current' ? 'key' : 'act') + '">' + (m.status === 'current' ? 'в списке' : 'новая') + '</span></td>' +
|
||||||
|
'<td class="asst-ru" style="padding:5px 6px;font-size:.75rem;color:#8a94a6">…</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
var goneRows = (r.gone || []).map(function (g) {
|
||||||
|
return '<tr style="opacity:.55"><td style="padding:5px 6px">—</td>' +
|
||||||
|
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem;text-decoration:line-through">' + esc(g.id) + '</td>' +
|
||||||
|
'<td style="padding:5px 6px">—</td><td style="padding:5px 6px"><span class="asst-bdg nokey">исчезла</span></td>' +
|
||||||
|
'<td style="padding:5px 6px;font-size:.75rem;color:#e0335e">будет убрана</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
res.innerHTML = '<div style="overflow-x:auto;margin-top:4px"><table style="width:100%;border-collapse:collapse">' +
|
||||||
|
'<thead><tr style="text-align:left;color:#8a94a6;font-size:.72rem;border-bottom:1px solid var(--border,#e2e8f0)">' +
|
||||||
|
'<th style="padding:4px 6px"></th><th style="padding:4px 6px">модель</th><th style="padding:4px 6px">ctx/out</th><th style="padding:4px 6px">статус</th><th style="padding:4px 6px">русский</th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + goneRows + '</tbody></table></div>' +
|
||||||
|
'<div style="margin-top:10px"><button id="asst-scan-apply" class="asst-ib primary" style="padding:8px 16px">Применить выбранные</button> ' +
|
||||||
|
'<span style="font-size:.74rem;color:#8a94a6">отмечены: текущие + новые с чистым русским</span></div>';
|
||||||
|
Q('#asst-scan-apply').addEventListener('click', applyScan);
|
||||||
|
// последовательный прогон тест-запросов
|
||||||
|
var trs = res.querySelectorAll('tbody tr[data-mid]');
|
||||||
|
for (var i = 0; i < trs.length; i++) {
|
||||||
|
var tr = trs[i], mid = tr.getAttribute('data-mid'), cell = tr.querySelector('.asst-ru');
|
||||||
|
cell.textContent = 'тест…';
|
||||||
|
try {
|
||||||
|
var pr = await LS.adminAssistantProbe(scanProvId, mid);
|
||||||
|
if (pr && pr.ok) {
|
||||||
|
var good = pr.cjk === 0 && pr.ratio > 55;
|
||||||
|
var col = good ? '#059652' : (pr.ratio > 20 && pr.cjk === 0) ? '#b45309' : '#e0335e';
|
||||||
|
cell.innerHTML = '<span style="color:' + col + '" title="' + esc(pr.sample || '') + '">' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с</span>';
|
||||||
|
if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; }
|
||||||
|
} else {
|
||||||
|
cell.innerHTML = '<span style="color:#e0335e">' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + '</span>';
|
||||||
|
var cb2 = tr.querySelector('.asst-scan-cb'); if (cb2) cb2.checked = false;
|
||||||
|
}
|
||||||
|
} catch (e) { cell.textContent = 'ошибка'; }
|
||||||
|
}
|
||||||
|
st.textContent = 'Готово. Отметьте нужные модели и нажмите «Применить выбранные».';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
Q('#asst-scan').addEventListener('click', runScan);
|
||||||
|
if (Q('#asst-scan-reset')) Q('#asst-scan-reset').addEventListener('click', async function () {
|
||||||
|
if (!await LS.confirm('Вернуть встроенный список бесплатных моделей?', { title: 'Сброс списка', confirmText: 'Вернуть' })) return;
|
||||||
|
try { await LS.adminAssistantApplyModels(null, true); LS.toast('Возвращён встроенный список', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
let inited = false;
|
let inited = false;
|
||||||
|
|
||||||
const GAME_FEATURES = [
|
const GAME_FEATURES = [
|
||||||
|
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
|
||||||
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
||||||
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
||||||
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
||||||
@@ -13,12 +14,17 @@
|
|||||||
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
||||||
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
|
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
|
||||||
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
|
{ 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: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
|
||||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||||
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
||||||
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
||||||
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
|
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
|
||||||
|
{ key: 'trainer', label: 'Тренажёр', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам (уравнения 7 класс), мгновенная проверка ответа подстановкой, прогресс по навыкам', icon: 'dumbbell' },
|
||||||
|
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FS_FEATURES = [
|
const FS_FEATURES = [
|
||||||
|
|||||||
@@ -452,6 +452,24 @@
|
|||||||
<i data-lucide="file-text"></i> Audit log
|
<i data-lucide="file-text"></i> Audit log
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ───────────────── */
|
/* ── wire quick-links via event delegation ───────────────── */
|
||||||
@@ -459,9 +477,119 @@
|
|||||||
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
|
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] });
|
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() {
|
async function load() {
|
||||||
const el = document.getElementById('overview-content');
|
const el = document.getElementById('overview-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
let inited = false;
|
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_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||||||
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
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 class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
|
||||||
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
|
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
|
||||||
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
|
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
|
||||||
|
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
|
||||||
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
|
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="q-card-actions">
|
<div class="q-card-actions">
|
||||||
|
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
|
||||||
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
|
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
|
||||||
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,16 +79,15 @@
|
|||||||
if (!inner) return;
|
if (!inner) return;
|
||||||
inner.innerHTML = '<div class="spinner"></div>';
|
inner.innerHTML = '<div class="spinner"></div>';
|
||||||
try {
|
try {
|
||||||
const [t, subjectQs] = await Promise.all([
|
const t = await LS.getTest(id);
|
||||||
LS.getTest(id),
|
|
||||||
LS.getQuestions(
|
|
||||||
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
|
|
||||||
null, 'date_asc'
|
|
||||||
).catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const inIds = new Set(t.questions.map(q => q.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 = `
|
inner.innerHTML = `
|
||||||
<div class="tst-cols">
|
<div class="tst-cols">
|
||||||
@@ -96,17 +97,89 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="tst-panel-title">Добавить вопросы</div>
|
<div class="tst-panel-title">Добавить вопросы</div>
|
||||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
|
<input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
|
||||||
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
|
<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>
|
||||||
</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);
|
AdminCtx.renderMath(inner);
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
|
await pickerLoad(id, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
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) {
|
function renderTstQList(questions, tid) {
|
||||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
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>';
|
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) {
|
function renderTstPicker(questions, inIds, tid) {
|
||||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
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 => {
|
return questions.map(q => {
|
||||||
const added = inIds.has(q.id);
|
const added = inIds.has(q.id);
|
||||||
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
||||||
@@ -145,15 +222,13 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filterTstPicker(tid) {
|
const _pickDebounce = {};
|
||||||
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
|
function filterTstPicker(tid) {
|
||||||
const cache = _tstPickerCache[tid];
|
const cache = _tstPickerCache[tid];
|
||||||
if (!cache) return;
|
if (!cache) return;
|
||||||
const filtered = search
|
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
|
||||||
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
|
clearTimeout(_pickDebounce[tid]);
|
||||||
: cache.subjectQs;
|
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
|
||||||
const picker = document.getElementById('tstpicker-'+tid);
|
|
||||||
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tstAddQ(tid, qid) {
|
async function tstAddQ(tid, qid) {
|
||||||
@@ -261,11 +336,27 @@
|
|||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
|
||||||
|
async function toggleTstAvail(id) {
|
||||||
|
const t = allTests.find(x => x.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
|
||||||
|
const next = t.available_to_students ? 0 : 1;
|
||||||
|
try {
|
||||||
|
await LS.updateTest(id, { available_to_students: next });
|
||||||
|
t.available_to_students = next;
|
||||||
|
renderTests();
|
||||||
|
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
// Expose handlers
|
// Expose handlers
|
||||||
window.loadTests = load;
|
window.loadTests = load;
|
||||||
window.renderTests = renderTests;
|
window.renderTests = renderTests;
|
||||||
window.toggleTstDrawer = toggleTstDrawer;
|
window.toggleTstDrawer = toggleTstDrawer;
|
||||||
window.filterTstPicker = filterTstPicker;
|
window.filterTstPicker = filterTstPicker;
|
||||||
|
window.pickerMore = pickerMore;
|
||||||
|
window.pickerFilterChange = pickerFilterChange;
|
||||||
window.tstAddQ = tstAddQ;
|
window.tstAddQ = tstAddQ;
|
||||||
window.tstRemoveQ = tstRemoveQ;
|
window.tstRemoveQ = tstRemoveQ;
|
||||||
window.setTstShowAnswers = setTstShowAnswers;
|
window.setTstShowAnswers = setTstShowAnswers;
|
||||||
@@ -274,6 +365,7 @@
|
|||||||
window.closeTstModal = closeTstModal;
|
window.closeTstModal = closeTstModal;
|
||||||
window.saveTst = saveTst;
|
window.saveTst = saveTst;
|
||||||
window.deleteTst = deleteTst;
|
window.deleteTst = deleteTst;
|
||||||
|
window.toggleTstAvail = toggleTstAvail;
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
window.AdminSections.tests = {
|
window.AdminSections.tests = {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
+274
-42
@@ -282,17 +282,33 @@
|
|||||||
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
|
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
|
||||||
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
|
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
|
||||||
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
|
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
|
||||||
'.asst-bubble{position:absolute;left:0;bottom:66px;width:380px;max-width:92vw;background:#fff;border-radius:18px;',
|
'.asst-name-face{display:inline-block;transition:transform .2s;}',
|
||||||
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);',
|
reduceMotion ? '' : '.asst-name-face.asst-think{animation:asstThink 1.3s ease-in-out infinite;transform-origin:60% 70%;}',
|
||||||
|
'@keyframes asstThink{0%,100%{transform:scale(1) rotate(0);}50%{transform:scale(1.08) rotate(-4deg);}}',
|
||||||
|
'.asst-bubble{position:absolute;left:0;bottom:66px;width:418px;max-width:94vw;background:rgba(255,255,255,.97);backdrop-filter:blur(16px) saturate(1.4);-webkit-backdrop-filter:blur(16px) saturate(1.4);border-radius:20px;overflow:hidden;',
|
||||||
|
' box-shadow:0 28px 70px -16px rgba(76,29,149,.38),0 6px 20px rgba(15,23,42,.1),0 0 0 1px rgba(155,93,229,.12);padding:14px 16px 16px;border:none;',
|
||||||
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
|
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
|
||||||
'.asst-name-face{display:inline-block;width:20px;height:20px;vertical-align:-4px;margin-right:7px;}',
|
'.asst-name-face{position:relative;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;vertical-align:middle;border-radius:50%;background:linear-gradient(135deg,rgba(255,255,255,.9),rgba(255,255,255,.55));margin-right:10px;box-shadow:inset 0 0 0 1px rgba(155,93,229,.28),0 2px 6px rgba(155,93,229,.18);}',
|
||||||
'.asst-name-face svg{width:100%;height:100%;display:block;}',
|
'.asst-name-face svg{width:25px;height:25px;display:block;}',
|
||||||
'.asst-memnote{font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:8px;}',
|
'.asst-name-face::after{content:"";position:absolute;right:-1px;bottom:0;width:9px;height:9px;border-radius:50%;background:#22c55e;border:2px solid #fff;}',
|
||||||
|
reduceMotion ? '' : '.asst-name-face::after{animation:asstOnline 2.2s ease-out infinite;}',
|
||||||
|
'@keyframes asstOnline{0%{box-shadow:0 0 0 0 rgba(34,197,94,.5);}70%,100%{box-shadow:0 0 0 6px rgba(34,197,94,0);}}',
|
||||||
|
// анимированный индикатор печати
|
||||||
|
'.asst-typing{display:inline-flex;gap:4px;align-items:center;padding:3px 0;}',
|
||||||
|
'.asst-typing span{width:7px;height:7px;border-radius:50%;background:#9B5DE5;opacity:.45;}',
|
||||||
|
reduceMotion ? '' : '.asst-typing span{animation:asstDot 1.1s infinite ease-in-out;}.asst-typing span:nth-child(2){animation-delay:.16s;}.asst-typing span:nth-child(3){animation-delay:.32s;}',
|
||||||
|
'@keyframes asstDot{0%,80%,100%{transform:translateY(0);opacity:.4;}40%{transform:translateY(-5px);opacity:1;}}',
|
||||||
|
// плавное появление сообщений
|
||||||
|
reduceMotion ? '' : '.asst-msg{animation:asstMsgIn .26s cubic-bezier(.4,0,.2,1);}',
|
||||||
|
'@keyframes asstMsgIn{from{opacity:0;transform:translateY(7px);}to{opacity:1;transform:translateY(0);}}',
|
||||||
|
'.asst-memnote{display:flex;gap:7px;align-items:flex-start;font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:9px;}',
|
||||||
|
'.asst-memnote .ic{width:13px;height:13px;flex:none;margin-top:1px;color:#b6abdd;}',
|
||||||
|
'.asst-memnote b{color:#7e3eca;font-weight:700;}',
|
||||||
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
|
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
|
||||||
'.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;',
|
'.asst-x{position:absolute;top:12px;right:12px;width:28px;height:28px;border:none;background:transparent;color:#94a3b8;z-index:3;',
|
||||||
' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}',
|
' cursor:pointer;border-radius:8px;font-size:19px;line-height:1;transition:all .14s;}',
|
||||||
'.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}',
|
'.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}',
|
||||||
'.asst-name{font-size:.7rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;}',
|
'.asst-name{font-size:.98rem;font-weight:800;color:#0F172A;text-transform:none;letter-spacing:0;margin:-14px -16px 12px;padding:13px 48px 12px 16px;background:linear-gradient(120deg,rgba(155,93,229,.13),rgba(6,182,212,.10));border-bottom:1px solid rgba(155,93,229,.14);border-radius:20px 20px 0 0;line-height:34px;}',
|
||||||
'.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}',
|
'.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}',
|
||||||
'.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}',
|
'.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}',
|
||||||
'.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;',
|
'.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;',
|
||||||
@@ -300,7 +316,13 @@
|
|||||||
'.asst-btn:hover{background:#7e3eca;}',
|
'.asst-btn:hover{background:#7e3eca;}',
|
||||||
'.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}',
|
'.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}',
|
||||||
'.asst-link:hover{color:#9B5DE5;}',
|
'.asst-link:hover{color:#9B5DE5;}',
|
||||||
'.asst-ask-in{width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #e2e8f0;border-radius:10px;font:inherit;font-size:.84rem;margin-bottom:10px;}',
|
'.asst-ask-row{display:flex;gap:8px;align-items:center;margin-bottom:4px;}',
|
||||||
|
'.asst-ask-in{flex:1;min-width:0;box-sizing:border-box;padding:11px 14px;border:1.5px solid #e6e3f2;border-radius:13px;font:inherit;font-size:.85rem;background:#faf9fd;transition:border-color .15s,box-shadow .15s,background .15s;}',
|
||||||
|
'.asst-ask-in:focus{outline:none;border-color:#9B5DE5;background:#fff;box-shadow:0 0 0 3px rgba(155,93,229,.13);}',
|
||||||
|
'.asst-send{flex-shrink:0;width:42px;height:42px;border:none;border-radius:13px;background:linear-gradient(135deg,#9B5DE5,#7d3fc8);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .12s,box-shadow .15s;box-shadow:0 4px 14px rgba(155,93,229,.32);}',
|
||||||
|
'.asst-send:hover{transform:translateY(-1px);box-shadow:0 7px 18px rgba(155,93,229,.42);}',
|
||||||
|
'.asst-send:active{transform:translateY(0);}',
|
||||||
|
'.asst-send svg{width:19px;height:19px;}',
|
||||||
'.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}',
|
'.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}',
|
||||||
'.asst-ans:first-of-type{border-top:none;}',
|
'.asst-ans:first-of-type{border-top:none;}',
|
||||||
'.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}',
|
'.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}',
|
||||||
@@ -308,7 +330,7 @@
|
|||||||
'.asst-ans-sec{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:12px 0 2px;}',
|
'.asst-ans-sec{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:12px 0 2px;}',
|
||||||
'.asst-ans-box{max-height:46vh;overflow:auto;}',
|
'.asst-ans-box{max-height:46vh;overflow:auto;}',
|
||||||
'.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}',
|
'.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}',
|
||||||
'.asst-chip{border:1px solid #e2e8f0;background:#f8fafc;border-radius:99px;padding:5px 10px;font:600 .72rem Manrope,sans-serif;color:#475569;cursor:pointer;text-align:left;}',
|
'.asst-chip{border:1px solid #e6e3f2;background:#faf9fd;border-radius:99px;padding:6px 12px;font:600 .72rem Manrope,sans-serif;color:#475569;cursor:pointer;text-align:left;transition:all .14s;}',
|
||||||
'.asst-chip:hover{border-color:#9B5DE5;color:#9B5DE5;}',
|
'.asst-chip:hover{border-color:#9B5DE5;color:#9B5DE5;}',
|
||||||
'.asst-chip-ctx{background:rgba(155,93,229,.1);border-color:rgba(155,93,229,.35);color:#7e3eca;}',
|
'.asst-chip-ctx{background:rgba(155,93,229,.1);border-color:rgba(155,93,229,.35);color:#7e3eca;}',
|
||||||
'.asst-rich{font-size:.84rem;line-height:1.55;color:#28324a;}',
|
'.asst-rich{font-size:.84rem;line-height:1.55;color:#28324a;}',
|
||||||
@@ -322,18 +344,29 @@
|
|||||||
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
|
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
|
||||||
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
|
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
|
||||||
'.asst-rich .katex{max-width:100%;}',
|
'.asst-rich .katex{max-width:100%;}',
|
||||||
|
// мигающий курсор во время стриминга ответа (CSS-каретка, без глифа)
|
||||||
|
'.asst-streaming{white-space:pre-wrap;}',
|
||||||
|
'.asst-streaming::after{content:"";display:inline-block;width:2px;height:1em;vertical-align:-2px;margin-left:2px;background:#9B5DE5;animation:asst-blink 1s steps(2) infinite;}',
|
||||||
|
'@keyframes asst-blink{50%{opacity:0;}}',
|
||||||
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
|
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
|
||||||
'.asst-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}',
|
'.asst-chat{max-height:54vh;overflow:auto;display:flex;flex-direction:column;gap:9px;margin-bottom:10px;padding-right:5px;}',
|
||||||
|
'.asst-chat::-webkit-scrollbar{width:7px;}',
|
||||||
|
'.asst-chat::-webkit-scrollbar-thumb{background:rgba(155,93,229,.28);border-radius:99px;}',
|
||||||
|
'.asst-chat::-webkit-scrollbar-track{background:transparent;}',
|
||||||
'.asst-chat:empty{display:none;}',
|
'.asst-chat:empty{display:none;}',
|
||||||
'.asst-msg{font-size:.84rem;line-height:1.5;border-radius:12px;padding:8px 11px;max-width:92%;word-break:break-word;}',
|
'.asst-msg{font-size:.85rem;line-height:1.55;border-radius:15px;padding:9px 13px;max-width:88%;word-break:break-word;box-shadow:0 1px 3px rgba(15,23,42,.06);}',
|
||||||
'.asst-msg-user{align-self:flex-end;background:#9B5DE5;color:#fff;}',
|
'.asst-msg-user{align-self:flex-end;background:linear-gradient(135deg,#9B5DE5,#7d3fc8);color:#fff;border-bottom-right-radius:5px;}',
|
||||||
'.asst-msg-assistant{align-self:flex-start;background:rgba(15,23,42,.05);max-width:100%;}',
|
'.asst-msg-assistant{align-self:flex-start;background:#f6f4fc;border:1px solid rgba(155,93,229,.1);max-width:100%;border-bottom-left-radius:5px;}',
|
||||||
'.asst-msg-assistant .asst-rich{color:#28324a;}',
|
'.asst-msg-assistant .asst-rich{color:#28324a;}',
|
||||||
'.asst-msg-ph{opacity:.6;}',
|
'.asst-msg-ph{opacity:.6;}',
|
||||||
'.asst-msg-links{align-self:flex-start;font-size:.74rem;}',
|
'.asst-msg-links{align-self:flex-start;font-size:.74rem;}',
|
||||||
'.asst-modes{display:flex;gap:6px;margin:2px 0 8px;}',
|
'.asst-modes{display:flex;gap:5px;margin:2px 0 9px;}',
|
||||||
'.asst-mode{flex:1;border:1px solid #e2e8f0;background:#f8fafc;border-radius:8px;padding:5px 6px;font:700 .68rem Manrope,sans-serif;color:#475569;cursor:pointer;}',
|
'.asst-mode{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:1px solid #ece9f6;background:#faf9fd;border-radius:11px;padding:7px 3px;font:700 .6rem Manrope,sans-serif;color:#64748b;cursor:pointer;transition:all .14s;}',
|
||||||
'.asst-mode.on{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}',
|
'.asst-mode .ic{width:15px;height:15px;opacity:.85;}',
|
||||||
|
'.asst-mode span{white-space:nowrap;}',
|
||||||
|
'.asst-mode:hover{border-color:#cdbdf2;color:#7e3eca;background:#fff;}',
|
||||||
|
'.asst-mode.on{background:linear-gradient(135deg,#9B5DE5,#7d3fc8);border-color:transparent;color:#fff;box-shadow:0 4px 11px rgba(155,93,229,.3);}',
|
||||||
|
'.asst-mode.on .ic{opacity:1;}',
|
||||||
'.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}',
|
'.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}',
|
||||||
'.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}',
|
'.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}',
|
||||||
'.asst-fb{align-self:flex-start;display:flex;gap:6px;}',
|
'.asst-fb{align-self:flex-start;display:flex;gap:6px;}',
|
||||||
@@ -347,6 +380,7 @@
|
|||||||
'.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}',
|
'.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}',
|
||||||
'.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}',
|
'.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}',
|
||||||
'.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}',
|
'.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}',
|
||||||
|
'.asst-mem-cat{display:inline-block;font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#7e3eca;background:rgba(155,93,229,.1);border-radius:99px;padding:1px 7px;margin-right:7px;vertical-align:1px;}',
|
||||||
'.asst-mem-note:last-of-type{border-bottom:none;}',
|
'.asst-mem-note:last-of-type{border-bottom:none;}',
|
||||||
'.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}',
|
'.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}',
|
||||||
'.asst-mem-x:hover{color:#e0335e;}',
|
'.asst-mem-x:hover{color:#e0335e;}',
|
||||||
@@ -359,6 +393,8 @@
|
|||||||
|
|
||||||
/* ── рендер ──────────────────────────────────────────────────────────── */
|
/* ── рендер ──────────────────────────────────────────────────────────── */
|
||||||
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
|
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
|
||||||
|
// живость: лицо Квантика в шапке чата реагирует на состояние (думает/радуется/грустит)
|
||||||
|
function setNameFace(mood) { var f = bubble && bubble.querySelector && bubble.querySelector('.asst-name-face'); if (f) { f.innerHTML = faceSVG(mood === 'thinking' ? 'neutral' : mood); f.classList.toggle('asst-think', mood === 'thinking'); } }
|
||||||
|
|
||||||
function openBubble(html, opts) {
|
function openBubble(html, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
@@ -476,17 +512,57 @@
|
|||||||
var h = sec.querySelector('.sec-h');
|
var h = sec.querySelector('.sec-h');
|
||||||
var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
|
var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
|
||||||
var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
|
var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
|
||||||
if (text.length > 40) return { title: title, text: text };
|
if (text.length > 40) return { title: title, text: text, kind: 'textbook' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (PAGE === 'theory') {
|
||||||
|
var c = document.querySelector('.lesson-content, .lsn-content, .lesson-body, #lesson-content, article.lesson, .course-lesson, .lesson-view');
|
||||||
|
if (c) {
|
||||||
|
var lt = document.querySelector('h1, .lsn-title, .lesson-title');
|
||||||
|
var ltitle = (lt && lt.textContent.trim()) || (document.title || 'Урок').split('·')[0].trim();
|
||||||
|
var ltext = (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
|
||||||
|
if (ltext.length > 60) return { title: ltitle, text: ltext, kind: 'lesson' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Лёгкий ситуативный контекст для ЛЮБОГО вопроса — где сейчас ученик (заголовок+раздел).
|
||||||
|
var PAGE_LABEL = { textbook: 'учебник', theory: 'урок/курс', exam: 'экзамен или тест', flashcards: 'флешкарты',
|
||||||
|
lab: 'лаборатория', homework: 'домашние задания', dashboard: 'главная', knowledge: 'карта знаний',
|
||||||
|
library: 'библиотека', analytics: 'аналитика', gradebook: 'журнал', qbank: 'банк вопросов' };
|
||||||
|
function pageHint() {
|
||||||
|
try {
|
||||||
|
var label = PAGE_LABEL[PAGE] || '';
|
||||||
|
var hEl = document.querySelector('.sec.active .sec-h, h1, .lsn-title, .lesson-title, .page-title');
|
||||||
|
var title = (hEl && hEl.textContent.trim()) || (document.title || '').split('·')[0].split('|')[0].trim();
|
||||||
|
title = title.replace(/\s+/g, ' ').slice(0, 120);
|
||||||
|
if (!title && !label) return '';
|
||||||
|
return 'Ученик сейчас на странице платформы' + (label ? ' («' + label + '»)' : '') + (title ? ': «' + title + '»' : '') + '. Учитывай это, если вопрос относится к материалу страницы.';
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
|
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
|
||||||
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
||||||
var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?'];
|
var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?'];
|
||||||
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
|
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
|
||||||
|
// Диалог переживает обновление/переходы (localStorage, per-user), пока ученик сам не нажмёт «Очистить»
|
||||||
|
// или пока не пройдёт CHAT_TTL без новых сообщений (срок жизни от последней реплики).
|
||||||
|
var CHAT_TTL = 7 * 24 * 3600 * 1000; // 7 дней
|
||||||
|
function _chatKey() { try { var u = LS.getUser && LS.getUser(); return u && u.id ? 'asst_chat_' + u.id : null; } catch (e) { return null; } }
|
||||||
|
function saveChat() { var k = _chatKey(); if (k) lsSet(k, JSON.stringify({ t: Date.now(), c: _chat.slice(-30) })); }
|
||||||
|
function loadChat() {
|
||||||
|
var k = _chatKey(); if (!k) return;
|
||||||
|
try {
|
||||||
|
var raw = JSON.parse(lsGet(k) || 'null'), arr, ts = null;
|
||||||
|
if (Array.isArray(raw)) arr = raw; // старый формат (без метки времени)
|
||||||
|
else if (raw && Array.isArray(raw.c)) { arr = raw.c; ts = raw.t; }
|
||||||
|
else return;
|
||||||
|
if (ts && (Date.now() - ts) > CHAT_TTL) { clearChatStore(); return; } // протух — забываем
|
||||||
|
_chat = arr.filter(function (m) { return m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string'; });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function clearChatStore() { var k = _chatKey(); if (k) try { localStorage.removeItem(k); } catch (e) {} }
|
||||||
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
|
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
|
||||||
function renderChat(chatEl) {
|
function renderChat(chatEl) {
|
||||||
chatEl.innerHTML = '';
|
chatEl.innerHTML = '';
|
||||||
@@ -501,37 +577,50 @@
|
|||||||
}
|
}
|
||||||
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
|
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
|
||||||
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
|
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
|
||||||
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' };
|
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' };
|
||||||
|
var _svg = function (p) { return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + p + '</svg>'; };
|
||||||
|
var MODE_DEFS = [
|
||||||
|
{ m: 'answer', label: 'Ответ', title: 'Ответить на вопрос', ic: _svg('<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>') },
|
||||||
|
{ m: 'hint', label: 'Подсказка', title: 'Подсказать, не решая целиком', ic: _svg('<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>') },
|
||||||
|
{ m: 'check', label: 'Проверить', title: 'Проверить твоё решение', ic: _svg('<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>') },
|
||||||
|
{ m: 'quiz', label: 'В банк', title: 'Сгенерировать вопросы в банк', ic: _svg('<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>'), tch: true },
|
||||||
|
{ m: 'draw', label: 'Рисунок', title: 'Нарисовать картинку', ic: _svg('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>') }
|
||||||
|
];
|
||||||
function openAsk(prefill) {
|
function openAsk(prefill) {
|
||||||
var sel = _lastSel, pc = getPageContext();
|
var sel = _lastSel, pc = getPageContext();
|
||||||
|
var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф';
|
||||||
|
var noun2 = pc && pc.kind === 'lesson' ? 'урока' : 'параграфа';
|
||||||
var ctxBtns = '';
|
var ctxBtns = '';
|
||||||
if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>';
|
if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>';
|
||||||
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' +
|
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить ' + noun + '</button>' +
|
||||||
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' +
|
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект ' + noun2 + '</button>' +
|
||||||
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из параграфа</button>';
|
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из ' + noun2 + '</button>';
|
||||||
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
|
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
|
||||||
var chips = '<div class="asst-chips">' + ctxBtns +
|
var chips = '<div class="asst-chips">' + ctxBtns +
|
||||||
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
|
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
|
||||||
var modes = '<div class="asst-modes">' +
|
var isTch = (_role === 'teacher' || _role === 'admin');
|
||||||
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
|
var modes = '<div class="asst-modes">' + MODE_DEFS.filter(function (d) { return !d.tch || isTch; })
|
||||||
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
|
.map(function (d) { return '<button class="asst-mode' + (d.m === 'answer' ? ' on' : '') + '" data-m="' + d.m + '" title="' + d.title + '">' + d.ic + '<span>' + d.label + '</span></button>'; })
|
||||||
'<button class="asst-mode" data-m="check">Проверить решение</button>' +
|
.join('') + '</div>';
|
||||||
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
|
|
||||||
openBubble(
|
openBubble(
|
||||||
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
|
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
|
||||||
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
|
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
|
||||||
(_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' +
|
(_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' +
|
||||||
'<div class="asst-chat"></div>' + chips + modes +
|
'<div class="asst-chat"></div>' + chips + modes +
|
||||||
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
|
'<div class="asst-ask-row"><input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
|
||||||
'<div class="asst-memnote">Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.</div>', {});
|
'<button class="asst-send" type="button" title="Отправить" aria-label="Отправить"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4 20-7z"/></svg></button></div>' +
|
||||||
|
'<div class="asst-memnote">' + _svg('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>') +
|
||||||
|
'<span>Держу в голове ход беседы — последние <b>~14 сообщений</b>. Диалог сохраняется между заходами, пока ты сам не нажмёшь «Очистить» или пока не пройдёт неделя без общения.</span></div>', {});
|
||||||
var inp = bubble.querySelector('.asst-ask-in');
|
var inp = bubble.querySelector('.asst-ask-in');
|
||||||
var chatEl = bubble.querySelector('.asst-chat');
|
var chatEl = bubble.querySelector('.asst-chat');
|
||||||
var chipsEl = bubble.querySelector('.asst-chips');
|
var chipsEl = bubble.querySelector('.asst-chips');
|
||||||
var mode = 'answer';
|
var mode = 'answer';
|
||||||
renderChat(chatEl);
|
renderChat(chatEl);
|
||||||
if (_chat.length) chipsEl.style.display = 'none';
|
if (_chat.length) chipsEl.style.display = 'none';
|
||||||
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl, m || mode); }
|
// свободный вопрос (context не задан явно) → подмешиваем лёгкий ситуативный контекст страницы
|
||||||
|
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; if (context == null) context = pageHint() || undefined; send(q, context, chatEl, m || mode); }
|
||||||
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
|
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
|
||||||
|
var sendBtn = bubble.querySelector('.asst-send'); if (sendBtn) sendBtn.addEventListener('click', function () { go(inp.value); });
|
||||||
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
|
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
mode = b.getAttribute('data-m');
|
mode = b.getAttribute('data-m');
|
||||||
@@ -550,7 +639,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
var clr = bubble.querySelector('[data-a="clear"]');
|
var clr = bubble.querySelector('[data-a="clear"]');
|
||||||
if (clr) clr.onclick = function () { _chat = []; openAsk(); };
|
if (clr) clr.onclick = function () { _chat = []; clearChatStore(); openAsk(); };
|
||||||
var memBtn = bubble.querySelector('[data-a="mem"]');
|
var memBtn = bubble.querySelector('[data-a="mem"]');
|
||||||
if (memBtn) memBtn.onclick = openMemory;
|
if (memBtn) memBtn.onclick = openMemory;
|
||||||
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
|
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
|
||||||
@@ -565,13 +654,18 @@
|
|||||||
if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', '));
|
if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', '));
|
||||||
if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', '));
|
if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', '));
|
||||||
if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.');
|
if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.');
|
||||||
var notes = (m.notes || []).map(function (n) { return '<div class="asst-mem-note"><span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">×</button></div>'; }).join('');
|
var MEM_CAT = { difficulty: 'трудность', goal: 'цель', preference: 'предпочтение', strength: 'сильная сторона', personal: 'личное', note: 'заметка' };
|
||||||
|
var notes = (m.notes || []).map(function (n) {
|
||||||
|
var cat = MEM_CAT[n.kind] || 'заметка';
|
||||||
|
return '<div class="asst-mem-note"><span><span class="asst-mem-cat">' + esc(cat) + '</span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">×</button></div>';
|
||||||
|
}).join('');
|
||||||
|
var forgettable = (m.notes && m.notes.length) || (p.weakSubjects && p.weakSubjects.length) || (p.weakTopics && p.weakTopics.length);
|
||||||
var body = m.enabled === false
|
var body = m.enabled === false
|
||||||
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
|
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
|
||||||
: '<div class="asst-mem-body">' +
|
: '<div class="asst-mem-body">' +
|
||||||
(prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '</div>' : '') +
|
(prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '<div style="font-size:.66rem;color:#9aa5b4;margin-top:7px">Считается по твоей активности и обновляется автоматически.</div></div>' : '') +
|
||||||
(notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) +
|
(notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) +
|
||||||
((notes || prof.length) ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') +
|
(forgettable ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
openBubble(
|
openBubble(
|
||||||
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' +
|
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' +
|
||||||
@@ -590,15 +684,15 @@
|
|||||||
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
|
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
|
||||||
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
|
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
|
||||||
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
|
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
|
||||||
chatEl.scrollTop = chatEl.scrollHeight;
|
chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
|
||||||
LS.imageGen(prompt).then(function (r) {
|
LS.imageGen(prompt).then(function (r) {
|
||||||
ph.remove();
|
ph.remove();
|
||||||
var d = msgEl('assistant');
|
var d = msgEl('assistant');
|
||||||
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); }
|
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); saveChat(); setNameFace('ecstatic'); }
|
||||||
else d.textContent = 'Не получилось нарисовать.';
|
else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
|
||||||
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
ph.remove(); var d = msgEl('assistant');
|
ph.remove(); var d = msgEl('assistant'); setNameFace('sad');
|
||||||
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
|
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
|
||||||
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
});
|
});
|
||||||
@@ -607,11 +701,90 @@
|
|||||||
q = (q || '').trim();
|
q = (q || '').trim();
|
||||||
if (q.length < 2) return;
|
if (q.length < 2) return;
|
||||||
if (mode === 'draw') return drawInChat(q, chatEl);
|
if (mode === 'draw') return drawInChat(q, chatEl);
|
||||||
var history = _chat.slice(-6);
|
if (mode === 'quiz') return makeQuiz(q, chatEl);
|
||||||
|
// стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь
|
||||||
|
if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode);
|
||||||
|
|
||||||
|
var history = _chat.slice(-14);
|
||||||
_chat.push({ role: 'user', content: q });
|
_chat.push({ role: 'user', content: q });
|
||||||
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
||||||
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
|
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.innerHTML = '<span class="asst-typing"><span></span><span></span><span></span></span>'; chatEl.appendChild(ph);
|
||||||
chatEl.scrollTop = chatEl.scrollHeight;
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
setNameFace('thinking');
|
||||||
|
|
||||||
|
var searchP = (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; });
|
||||||
|
var meta = { answers: [], sources: [] }, full = '', msgD = null, richEl = null, streamed = false, finalized = false;
|
||||||
|
|
||||||
|
function ensureMsg() {
|
||||||
|
if (msgD) return;
|
||||||
|
if (ph.parentNode) ph.remove();
|
||||||
|
msgD = msgEl('assistant'); msgD.innerHTML = '<div class="asst-rich asst-streaming"></div>';
|
||||||
|
richEl = msgD.querySelector('.asst-rich'); chatEl.appendChild(msgD);
|
||||||
|
}
|
||||||
|
function finalize(done) {
|
||||||
|
if (finalized) return; finalized = true;
|
||||||
|
done = done || {};
|
||||||
|
var src = done.source;
|
||||||
|
if ((src === 'limit' || src === 'error') && !full) {
|
||||||
|
_chat.pop();
|
||||||
|
if (msgD) msgD.remove(); if (ph.parentNode) ph.remove();
|
||||||
|
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = done.answer || 'Сейчас не получилось. Попробуй ещё раз.';
|
||||||
|
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
|
||||||
|
}
|
||||||
|
var isModel = src === 'model' && (full || done.answer);
|
||||||
|
setNameFace(isModel ? 'happy' : 'neutral');
|
||||||
|
searchP.then(function (sres) {
|
||||||
|
var found = (sres && sres.results) || [];
|
||||||
|
var ansArr = (done.answers && done.answers.length ? done.answers : meta.answers) || [];
|
||||||
|
var sources = done.sources || meta.sources || [];
|
||||||
|
var content = isModel ? (full || done.answer) : ((ansArr[0] && (ansArr[0].q + '\n' + ansArr[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).');
|
||||||
|
ensureMsg(); richEl.classList.remove('asst-streaming');
|
||||||
|
_chat.push({ role: 'assistant', content: content }); saveChat();
|
||||||
|
renderRich(richEl, content);
|
||||||
|
if (isModel && sources.length) {
|
||||||
|
var sc = document.createElement('div'); sc.className = 'asst-src';
|
||||||
|
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(safeUrl(srcUrl(s))) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
|
||||||
|
chatEl.appendChild(sc);
|
||||||
|
}
|
||||||
|
var links = '';
|
||||||
|
if (!isModel && ansArr.length) links += ansArr.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(safeUrl(a.url)) + '">' + esc(a.q) + '</a>'; }).join(' · ');
|
||||||
|
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(safeUrl(f.url)) + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
|
||||||
|
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
|
||||||
|
if (isModel) {
|
||||||
|
var fb = document.createElement('div'); fb.className = 'asst-fb';
|
||||||
|
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
|
||||||
|
fb.querySelectorAll('button').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { if (fb.dataset.done) return; fb.dataset.done = '1'; b.classList.add('on'); try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {} });
|
||||||
|
});
|
||||||
|
chatEl.appendChild(fb);
|
||||||
|
}
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
LS.assistantAskStream(q, context, history, mode, {
|
||||||
|
onMeta: function (m) { if (m.answers) meta.answers = m.answers; if (m.sources) meta.sources = m.sources; },
|
||||||
|
onDelta: function (t) { streamed = true; ensureMsg(); full += t; richEl.textContent = full; chatEl.scrollTop = chatEl.scrollHeight; },
|
||||||
|
onDone: function (o) { finalize(o); },
|
||||||
|
}).then(function () { if (!finalized) finalize({ source: full ? 'model' : 'faq' }); })
|
||||||
|
.catch(function () {
|
||||||
|
if (finalized) return;
|
||||||
|
if (!streamed) { if (ph.parentNode) ph.remove(); _chat.pop(); sendNonStream(q, context, chatEl, mode); }
|
||||||
|
else finalize({ source: 'model' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNonStream(q, context, chatEl, mode) {
|
||||||
|
q = (q || '').trim();
|
||||||
|
if (q.length < 2) return;
|
||||||
|
if (mode === 'draw') return drawInChat(q, chatEl);
|
||||||
|
if (mode === 'quiz') return makeQuiz(q, chatEl);
|
||||||
|
var history = _chat.slice(-14);
|
||||||
|
_chat.push({ role: 'user', content: q });
|
||||||
|
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
||||||
|
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.innerHTML = '<span class="asst-typing"><span></span><span></span><span></span></span>'; chatEl.appendChild(ph);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
setNameFace('thinking');
|
||||||
Promise.all([
|
Promise.all([
|
||||||
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
|
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
|
||||||
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||||
@@ -622,14 +795,15 @@
|
|||||||
if (r0.source === 'limit' || r0.source === 'error') {
|
if (r0.source === 'limit' || r0.source === 'error') {
|
||||||
_chat.pop();
|
_chat.pop();
|
||||||
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
|
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
|
||||||
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return;
|
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
|
||||||
}
|
}
|
||||||
var model = r0.source === 'model' ? r0.answer : null;
|
var model = r0.source === 'model' ? r0.answer : null;
|
||||||
|
setNameFace(model ? 'happy' : 'neutral');
|
||||||
var ans = r0.answers || [];
|
var ans = r0.answers || [];
|
||||||
var sources = r0.sources || [];
|
var sources = r0.sources || [];
|
||||||
var found = (res[1] && res[1].results) || [];
|
var found = (res[1] && res[1].results) || [];
|
||||||
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
|
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
|
||||||
_chat.push({ role: 'assistant', content: content });
|
_chat.push({ role: 'assistant', content: content }); saveChat();
|
||||||
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
|
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
|
||||||
renderRich(d.querySelector('.asst-rich'), content);
|
renderRich(d.querySelector('.asst-rich'), content);
|
||||||
// источники (RAG)
|
// источники (RAG)
|
||||||
@@ -680,6 +854,63 @@
|
|||||||
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; });
|
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── «Тест в банк» (учитель): модель → вопросы → банк вопросов ─────────── */
|
||||||
|
function makeQuiz(topic, chatEl) {
|
||||||
|
topic = (topic || '').trim();
|
||||||
|
var note = msgEl('assistant');
|
||||||
|
note.innerHTML = '<div class="asst-rich">Составляю тестовые вопросы…</div>';
|
||||||
|
chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
|
||||||
|
Promise.all([
|
||||||
|
LS.assistantQuestions(topic, 5),
|
||||||
|
(LS.getSubjects ? LS.getSubjects() : Promise.resolve([])).catch(function () { return []; }),
|
||||||
|
]).then(function (res) {
|
||||||
|
var qs = (res[0] && res[0].questions) || [];
|
||||||
|
var subjects = Array.isArray(res[1]) ? res[1] : ((res[1] && res[1].subjects) || []);
|
||||||
|
if (!qs.length) { note.innerHTML = '<div class="asst-rich">Не получилось сгенерировать вопросы. Уточни тему и попробуй ещё.</div>'; setNameFace('sad'); return; }
|
||||||
|
note.remove();
|
||||||
|
var wrap = msgEl('assistant'); wrap.style.maxWidth = '100%';
|
||||||
|
var box = document.createElement('div'); box.className = 'asst-rich'; wrap.appendChild(box);
|
||||||
|
var head = document.createElement('div'); head.style.cssText = 'font-weight:800;margin-bottom:6px'; head.textContent = 'Вопросы (' + qs.length + ') — проверь и сохрани:'; box.appendChild(head);
|
||||||
|
qs.forEach(function (it, i) {
|
||||||
|
var qd = document.createElement('div'); qd.style.cssText = 'margin:8px 0;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:10px';
|
||||||
|
var qt = document.createElement('div'); qt.style.cssText = 'font-weight:700;margin-bottom:4px'; qt.appendChild(document.createTextNode((i + 1) + '. '));
|
||||||
|
var qr = document.createElement('span'); qt.appendChild(qr); renderRich(qr, it.q); qd.appendChild(qt);
|
||||||
|
(it.options || []).forEach(function (op, oi) {
|
||||||
|
var li = document.createElement('div'); li.style.cssText = 'padding:2px 0 2px 14px;font-size:.84rem' + (oi === it.correct ? ';color:#059652;font-weight:700' : '');
|
||||||
|
var os = document.createElement('span'); renderRich(os, op); li.appendChild(os);
|
||||||
|
if (oi === it.correct) { var okm = document.createElement('span'); okm.textContent = ' — верно'; okm.style.color = '#059652'; li.appendChild(okm); }
|
||||||
|
qd.appendChild(li);
|
||||||
|
});
|
||||||
|
if (it.explanation) { var ex = document.createElement('div'); ex.style.cssText = 'margin-top:4px;font-size:.8rem;color:#8a94a6'; ex.appendChild(document.createTextNode('Пояснение: ')); var exs = document.createElement('span'); renderRich(exs, it.explanation); ex.appendChild(exs); qd.appendChild(ex); }
|
||||||
|
box.appendChild(qd);
|
||||||
|
});
|
||||||
|
var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px';
|
||||||
|
var sel = document.createElement('select'); sel.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem';
|
||||||
|
sel.innerHTML = '<option value="">Предмет…</option>' + subjects.map(function (s) { return '<option value="' + esc(s.slug) + '">' + esc(s.name || s.slug) + '</option>'; }).join('');
|
||||||
|
var topicIn = document.createElement('input'); topicIn.type = 'text'; topicIn.placeholder = 'Тема (необязательно)'; topicIn.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem;flex:1;min-width:110px';
|
||||||
|
var saveB = document.createElement('button'); saveB.className = 'asst-chip'; saveB.type = 'button'; saveB.textContent = 'Сохранить в банк';
|
||||||
|
var st = document.createElement('span'); st.style.cssText = 'font-size:.8rem;color:#8a94a6';
|
||||||
|
bar.appendChild(sel); bar.appendChild(topicIn); bar.appendChild(saveB); bar.appendChild(st); box.appendChild(bar);
|
||||||
|
saveB.addEventListener('click', function () {
|
||||||
|
var slug = sel.value; if (!slug) { st.textContent = 'Выбери предмет'; return; }
|
||||||
|
saveB.disabled = true; st.textContent = 'Сохраняю…';
|
||||||
|
var topicName = topicIn.value.trim() || (topic.length <= 60 ? topic : '');
|
||||||
|
var done = 0;
|
||||||
|
qs.reduce(function (p, it) {
|
||||||
|
return p.then(function () {
|
||||||
|
return LS.createQuestion({ subject_slug: slug, topic_name: topicName || undefined, type: 'single', text: it.q, explanation: it.explanation || undefined, difficulty: 1, options: (it.options || []).map(function (t, i) { return { text: t, is_correct: i === it.correct }; }) }).then(function () { done++; }).catch(function () {});
|
||||||
|
});
|
||||||
|
}, Promise.resolve()).then(function () {
|
||||||
|
st.innerHTML = 'Сохранено ' + done + ' из ' + qs.length + '. <a class="asst-ans-link" href="/question-bank">Открыть банк вопросов</a>';
|
||||||
|
saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('ecstatic');
|
||||||
|
}).catch(function (e) {
|
||||||
|
note.innerHTML = '<div class="asst-rich">' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '</div>'; setNameFace('sad');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
|
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
|
||||||
var TOUR = [
|
var TOUR = [
|
||||||
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
|
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
|
||||||
@@ -844,6 +1075,7 @@
|
|||||||
SRV = ctx || {};
|
SRV = ctx || {};
|
||||||
_role = (SRV && SRV.role) || 'student';
|
_role = (SRV && SRV.role) || 'student';
|
||||||
if (SRV.enabled === false) return; // выключено пользователем
|
if (SRV.enabled === false) return; // выключено пользователем
|
||||||
|
loadChat(); // восстановить диалог прошлой сессии (per-user)
|
||||||
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
|
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
|
||||||
PET = pet || null;
|
PET = pet || null;
|
||||||
ensurePet(mount);
|
ensurePet(mount);
|
||||||
|
|||||||
@@ -197,7 +197,10 @@
|
|||||||
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
|
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
|
||||||
title: s.user_name || '—',
|
title: s.user_name || '—',
|
||||||
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
|
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;
|
const ab = d.abandonedSessions24h || 0;
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
const pickerOver = document.getElementById('vp-overlay');
|
const pickerOver = document.getElementById('vp-overlay');
|
||||||
const pickerGrid = document.getElementById('vp-grid');
|
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 ─────────────────────────────────────────── */
|
/* ── Picker overlay ─────────────────────────────────────────── */
|
||||||
function buildGrid() {
|
function buildGrid() {
|
||||||
pickerGrid.innerHTML = variants.map(v => {
|
pickerGrid.innerHTML = variants.map(v => {
|
||||||
@@ -45,7 +51,7 @@
|
|||||||
const active = v.n === currentN ? ' active' : '';
|
const active = v.n === currentN ? ' active' : '';
|
||||||
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||||
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
(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('');
|
}).join('');
|
||||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||||
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
|
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
|
||||||
@@ -74,7 +80,7 @@
|
|||||||
/* ── Variant rendering ──────────────────────────────────────── */
|
/* ── Variant rendering ──────────────────────────────────────── */
|
||||||
async function selectVariant(n) {
|
async function selectVariant(n) {
|
||||||
currentN = n;
|
currentN = n;
|
||||||
pickerLabel.textContent = `Вариант ${n}`;
|
pickerLabel.textContent = labelOf(n);
|
||||||
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
|
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
|
||||||
|
|
||||||
if (!tasksCache.has(n)) {
|
if (!tasksCache.has(n)) {
|
||||||
@@ -94,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderVariant(n, tasks) {
|
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 variantMeta = variants.find(v => v.n === n);
|
||||||
const solvedTracked = new Set(); // tasks already solved this session
|
const solvedTracked = new Set(); // tasks already solved this session
|
||||||
|
|||||||
@@ -760,8 +760,21 @@ window.ChemVisuals = (() => {
|
|||||||
return hex;
|
return hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Replace inline icon-SVG (reaction arrows) with Unicode for canvas fillText.
|
||||||
|
Canvas draws plain strings — embedded <svg> markup would show as raw text. */
|
||||||
|
function cleanIcons(s) {
|
||||||
|
if (!s || !s.includes('<svg')) return s;
|
||||||
|
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
|
||||||
|
if (m.includes('x1="5" y1="12" x2="19"')) return '→'; // right arrow
|
||||||
|
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '↓'; // down (precipitate)
|
||||||
|
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '↑'; // up (gas)
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Public API ─────────────────────────────────────────────── */
|
/* ── Public API ─────────────────────────────────────────────── */
|
||||||
return {
|
return {
|
||||||
|
cleanIcons,
|
||||||
drawErlenmeyer,
|
drawErlenmeyer,
|
||||||
drawBeaker,
|
drawBeaker,
|
||||||
drawBurette,
|
drawBurette,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
if (document.getElementById('lm-style')) return;
|
if (document.getElementById('lm-style')) return;
|
||||||
var s = document.createElement('style'); s.id = 'lm-style';
|
var s = document.createElement('style'); s.id = 'lm-style';
|
||||||
s.textContent = [
|
s.textContent = [
|
||||||
'#lm-svg{position:fixed;inset:0;z-index:60;pointer-events:none;display:none;}',
|
'#lm-svg{position:fixed;inset:0;width:100vw;height:100vh;z-index:60;pointer-events:none;display:none;}',
|
||||||
'#lm-svg.on{display:block;}',
|
'#lm-svg.on{display:block;}',
|
||||||
'#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}',
|
'#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}',
|
||||||
'#lm-svg .lm-hit:active{cursor:grabbing;}',
|
'#lm-svg .lm-hit:active{cursor:grabbing;}',
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
function setTool(t) {
|
function setTool(t) {
|
||||||
ensure();
|
ensure();
|
||||||
if (t === 'off') { mode = null; svg.classList.remove('on'); paintBar(); render(); return; }
|
if (t === 'off') { mode = null; svg.classList.remove('on'); bar.classList.remove('on'); paintBar(); render(); return; }
|
||||||
mode = t;
|
mode = t;
|
||||||
var c = center();
|
var c = center();
|
||||||
if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y };
|
if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y };
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
|
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
|
||||||
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
|
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
|
||||||
floor: 1, ceil: 1, round: 1, sign: 1,
|
floor: 1, ceil: 1, round: 1, sign: 1,
|
||||||
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1
|
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1,
|
||||||
|
gcd: 2, lcm: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
|
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
|
||||||
@@ -60,7 +61,20 @@
|
|||||||
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
|
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
|
||||||
min: Math.min, max: Math.max,
|
min: Math.min, max: Math.max,
|
||||||
mod: function (a, b) { return b === 0 ? 0 : a % b; },
|
mod: function (a, b) { return b === 0 ? 0 : a % b; },
|
||||||
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot
|
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot,
|
||||||
|
// НОД (алгоритм Евклида) и НОК — целочисленные, защищены от NaN/0/отрицательных
|
||||||
|
gcd: function (a, b) {
|
||||||
|
a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b));
|
||||||
|
if (!isFinite(a) || !isFinite(b)) return 0;
|
||||||
|
while (b) { var t = a % b; a = b; b = t; }
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
lcm: function (a, b) {
|
||||||
|
a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b));
|
||||||
|
if (!a || !b || !isFinite(a) || !isFinite(b)) return 0;
|
||||||
|
var x = a, y = b; while (y) { var t = x % y; x = y; y = t; }
|
||||||
|
return a / x * b;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
|
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
|
||||||
|
|||||||
@@ -1562,7 +1562,7 @@ class ChemSandboxSim {
|
|||||||
if (!isOk && this._quizTask) {
|
if (!isOk && this._quizTask) {
|
||||||
ctx.font = '10px "JetBrains Mono", monospace';
|
ctx.font = '10px "JetBrains Mono", monospace';
|
||||||
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
|
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
|
||||||
ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65);
|
ctx.fillText('Ответ: ' + _csClean(this._quizTask.rx.eq), W / 2, bannerY + 65);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1146,7 +1146,7 @@ class FlaskSim {
|
|||||||
const eqY = g.cy + g.r + 26;
|
const eqY = g.cy + g.r + 26;
|
||||||
|
|
||||||
ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)';
|
ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)';
|
||||||
ctx.textAlign = 'center'; ctx.fillText(eq, W * 0.44, eqY); ctx.textAlign = 'left';
|
ctx.textAlign = 'center'; ctx.fillText(ChemVisuals.cleanIcons(eq), W * 0.44, eqY); ctx.textAlign = 'left';
|
||||||
|
|
||||||
if (this._passiv) {
|
if (this._passiv) {
|
||||||
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166';
|
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166';
|
||||||
|
|||||||
+213
-23
@@ -16,6 +16,8 @@ class GraphSim {
|
|||||||
this.oy = 0; // viewport centre y (math units)
|
this.oy = 0; // viewport centre y (math units)
|
||||||
this.scl = 50; // px per unit
|
this.scl = 50; // px per unit
|
||||||
this.fns = []; // [{ color, fn } | null]
|
this.fns = []; // [{ color, fn } | null]
|
||||||
|
this._hidden = [false, false, false]; // показ/скрытие функции
|
||||||
|
this.showPts = false; // особые точки (нули/пересечения/y-перехват)
|
||||||
this.hx = null; // hovered x (math) or null
|
this.hx = null; // hovered x (math) or null
|
||||||
this._dg = null; // drag state
|
this._dg = null; // drag state
|
||||||
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
|
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
|
||||||
@@ -62,6 +64,8 @@ class GraphSim {
|
|||||||
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
|
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
|
||||||
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
|
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
|
||||||
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
|
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
|
||||||
|
setHidden(idx, v) { this._hidden[idx] = !!v; this.draw(); }
|
||||||
|
setShowPoints(v) { this.showPts = !!v; this.draw(); }
|
||||||
|
|
||||||
/* ── formula compiler (CSP-safe: no eval / new Function) ── */
|
/* ── formula compiler (CSP-safe: no eval / new Function) ── */
|
||||||
|
|
||||||
@@ -271,10 +275,14 @@ class GraphSim {
|
|||||||
|
|
||||||
this._drawGrid(c, W, H);
|
this._drawGrid(c, W, H);
|
||||||
this._drawAxes(c, W, H);
|
this._drawAxes(c, W, H);
|
||||||
for (const f of this.fns) if (f) this._drawCurve(c, W, H, f);
|
this.fns.forEach((f, i) => { if (f && !this._hidden[i]) this._drawCurve(c, W, H, f); });
|
||||||
|
if (this.showPts) this._drawPoints(c, W, H);
|
||||||
if (this.hx !== null) this._drawHover(c, W, H);
|
if (this.hx !== null) this._drawHover(c, W, H);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* видимые функции (для hover/особых точек) */
|
||||||
|
_visible() { return this.fns.map((f, i) => (f && !this._hidden[i]) ? f : null); }
|
||||||
|
|
||||||
/* ── grid ──────────────────────────────────── */
|
/* ── grid ──────────────────────────────────── */
|
||||||
|
|
||||||
_niceStep() {
|
_niceStep() {
|
||||||
@@ -414,21 +422,90 @@ class GraphSim {
|
|||||||
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
|
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
|
||||||
c.setLineDash([]);
|
c.setLineDash([]);
|
||||||
|
|
||||||
for (const f of this.fns) {
|
this.fns.forEach((f, i) => {
|
||||||
if (!f) continue;
|
if (!f || this._hidden[i]) return;
|
||||||
let my;
|
let my;
|
||||||
try { my = f.fn(this.hx); } catch { continue; }
|
try { my = f.fn(this.hx); } catch { return; }
|
||||||
if (!isFinite(my) || isNaN(my)) continue;
|
if (!isFinite(my) || isNaN(my)) return;
|
||||||
|
|
||||||
const [, py] = this._toPx(this.hx, my);
|
const [, py] = this._toPx(this.hx, my);
|
||||||
if (py < -20 || py > H + 20) continue;
|
if (py < -20 || py > H + 20) return;
|
||||||
|
|
||||||
c.fillStyle = f.color;
|
c.fillStyle = f.color;
|
||||||
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
|
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
|
||||||
c.strokeStyle = 'rgba(255,255,255,0.8)';
|
c.strokeStyle = 'rgba(255,255,255,0.8)';
|
||||||
c.lineWidth = 1.5; c.stroke();
|
c.lineWidth = 1.5; c.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── особые точки: нули, y-перехват, пересечения ─── */
|
||||||
|
_findZeros(g, a, b, samples) {
|
||||||
|
const zeros = []; const dx = (b - a) / samples; const eps = Math.abs(dx) * 0.25;
|
||||||
|
const push = (r) => { if (!zeros.length || Math.abs(r - zeros[zeros.length - 1]) > eps) zeros.push(r); };
|
||||||
|
let pmx = a, pv; try { pv = g(a); } catch { pv = NaN; }
|
||||||
|
if (isFinite(pv) && pv === 0) push(a);
|
||||||
|
for (let i = 1; i <= samples && zeros.length < 60; i++) {
|
||||||
|
const mx = a + i * dx;
|
||||||
|
let v; try { v = g(mx); } catch { v = NaN; }
|
||||||
|
if (isFinite(pv) && isFinite(v)) {
|
||||||
|
if (v === 0) { push(mx); } // точный ноль на узле сетки
|
||||||
|
else if (pv !== 0 && pv * v < 0) { // смена знака — бисекция
|
||||||
|
let lo = pmx, hi = mx, flo = pv;
|
||||||
|
for (let k = 0; k < 50; k++) {
|
||||||
|
const mid = (lo + hi) / 2; let fm; try { fm = g(mid); } catch { fm = NaN; }
|
||||||
|
if (!isFinite(fm)) { lo = hi = mid; break; }
|
||||||
|
if (flo * fm <= 0) hi = mid; else { lo = mid; flo = fm; }
|
||||||
|
}
|
||||||
|
push((lo + hi) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pmx = mx; pv = v;
|
||||||
|
}
|
||||||
|
return zeros;
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawPoints(c, W, H) {
|
||||||
|
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
|
||||||
|
const samples = Math.min(Math.round(W), 800);
|
||||||
|
const vis = this._visible();
|
||||||
|
const pts = []; // { mx, my, color, kind }
|
||||||
|
vis.forEach(f => {
|
||||||
|
if (!f) return;
|
||||||
|
// нули функции
|
||||||
|
this._findZeros(f.fn, x0, x1, samples).forEach(rx => pts.push({ mx: rx, my: 0, color: f.color, kind: 'root' }));
|
||||||
|
// y-перехват
|
||||||
|
if (x0 <= 0 && x1 >= 0) { let v; try { v = f.fn(0); } catch { v = NaN; } if (isFinite(v)) pts.push({ mx: 0, my: v, color: f.color, kind: 'yint' }); }
|
||||||
|
});
|
||||||
|
// пересечения пар
|
||||||
|
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) {
|
||||||
|
if (!vis[i] || !vis[j]) continue;
|
||||||
|
const fi = vis[i].fn, fj = vis[j].fn;
|
||||||
|
this._findZeros(x => fi(x) - fj(x), x0, x1, samples).forEach(ix => {
|
||||||
|
let v; try { v = fi(ix); } catch { v = NaN; }
|
||||||
|
if (isFinite(v)) pts.push({ mx: ix, my: v, color: '#ffffff', kind: 'cross' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const labels = pts.length <= 22; // не подписываем при «частоколе» (sin на широком диапазоне)
|
||||||
|
c.font = '600 10.5px Manrope, system-ui, sans-serif';
|
||||||
|
for (const p of pts) {
|
||||||
|
const [px, py] = this._toPx(p.mx, p.my);
|
||||||
|
if (px < -8 || px > W + 8 || py < -8 || py > H + 8) continue;
|
||||||
|
c.beginPath(); c.arc(px, py, 4.5, 0, 2 * Math.PI);
|
||||||
|
c.fillStyle = p.color; c.fill();
|
||||||
|
c.lineWidth = 1.5; c.strokeStyle = '#0D0D1A'; c.stroke();
|
||||||
|
if (labels) {
|
||||||
|
const tx = '(' + this._fmtP(p.mx) + '; ' + this._fmtP(p.my) + ')';
|
||||||
|
c.textAlign = 'left'; c.textBaseline = 'bottom';
|
||||||
|
const lx = Math.min(px + 7, W - c.measureText(tx).width - 4), ly = Math.max(12, py - 6);
|
||||||
|
c.fillStyle = 'rgba(13,13,26,0.78)';
|
||||||
|
const tw = c.measureText(tx).width;
|
||||||
|
c.fillRect(lx - 3, ly - 12, tw + 6, 14);
|
||||||
|
c.fillStyle = p.kind === 'cross' ? 'rgba(255,255,255,0.92)' : p.color;
|
||||||
|
c.fillText(tx, lx, ly);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_fmtP(n) { if (Math.abs(n) < 1e-9) return '0'; const r = Math.round(n * 100) / 100; return Number.isInteger(r) ? String(r) : r.toFixed(2); }
|
||||||
|
|
||||||
/* ── events ─────────────────────────────────── */
|
/* ── events ─────────────────────────────────── */
|
||||||
|
|
||||||
@@ -475,27 +552,41 @@ class GraphSim {
|
|||||||
});
|
});
|
||||||
cv.style.cursor = 'crosshair';
|
cv.style.cursor = 'crosshair';
|
||||||
|
|
||||||
/* touch drag */
|
/* touch: 1 палец — панорама, 2 пальца — пинч-зум к центру жеста */
|
||||||
let t0 = null;
|
let t0 = null, pinch = null;
|
||||||
|
const tDist = ts => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
|
||||||
cv.addEventListener('touchstart', e => {
|
cv.addEventListener('touchstart', e => {
|
||||||
if (e.touches.length === 1)
|
if (e.touches.length === 1) {
|
||||||
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
|
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; pinch = null;
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
pinch = { d: tDist(e.touches), scl: this.scl,
|
||||||
|
cx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
|
||||||
|
cy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top };
|
||||||
|
t0 = null;
|
||||||
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
cv.addEventListener('touchmove', e => {
|
cv.addEventListener('touchmove', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.touches.length === 1 && t0) {
|
if (e.touches.length === 2 && pinch) {
|
||||||
|
const [mx, my] = this._toMx(pinch.cx, pinch.cy);
|
||||||
|
this.scl = Math.max(4, Math.min(800, pinch.scl * (tDist(e.touches) / (pinch.d || 1))));
|
||||||
|
const [nx, ny] = this._toMx(pinch.cx, pinch.cy);
|
||||||
|
this.ox -= nx - mx; this.oy -= ny - my;
|
||||||
|
this.draw();
|
||||||
|
} else if (e.touches.length === 1 && t0) {
|
||||||
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
|
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
|
||||||
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
|
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
|
||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
cv.addEventListener('touchend', () => { t0 = null; });
|
cv.addEventListener('touchend', e => { if (e.touches.length === 0) { t0 = null; pinch = null; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitHover() {
|
_emitHover() {
|
||||||
if (!this.onHover) return;
|
if (!this.onHover) return;
|
||||||
const vals = this.fns.map(f => {
|
const vals = this.fns.map((f, i) => {
|
||||||
if (!f) return null;
|
if (!f || this._hidden[i]) return null;
|
||||||
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
|
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
|
||||||
});
|
});
|
||||||
this.onHover(this.hx, vals);
|
this.onHover(this.hx, vals);
|
||||||
@@ -507,6 +598,7 @@ class GraphSim {
|
|||||||
document.getElementById('sim-topbar-title').textContent = 'График функции';
|
document.getElementById('sim-topbar-title').textContent = 'График функции';
|
||||||
_simShow('sim-graph');
|
_simShow('sim-graph');
|
||||||
_simShow('ctrl-graph');
|
_simShow('ctrl-graph');
|
||||||
|
_initGraphPanel();
|
||||||
|
|
||||||
_registerSimState('graph',
|
_registerSimState('graph',
|
||||||
() => ({
|
() => ({
|
||||||
@@ -518,18 +610,20 @@ class GraphSim {
|
|||||||
const el = document.getElementById(`fn${i}`);
|
const el = document.getElementById(`fn${i}`);
|
||||||
if (el) { el.value = fn.expr; }
|
if (el) { el.value = fn.expr; }
|
||||||
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
|
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
|
||||||
|
_fnDisplay(i);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (_embedMode) _startStateEmit('graph');
|
if (_embedMode) _startStateEmit('graph');
|
||||||
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
_initGraphPanel(); // KaTeX к этому моменту точно загружен
|
||||||
if (!gSim) {
|
if (!gSim) {
|
||||||
gSim = new GraphSim(document.getElementById('graph-canvas'));
|
gSim = new GraphSim(document.getElementById('graph-canvas'));
|
||||||
gSim.onHover = updateInfoBar;
|
gSim.onHover = updateInfoBar;
|
||||||
if (!document.getElementById('fn0').value.trim()) {
|
if (!document.getElementById('fn0').value.trim()) {
|
||||||
document.getElementById('fn0').value = 'sin(x)';
|
document.getElementById('fn0').value = 'sin(x)';
|
||||||
renderPreview(0);
|
renderPreview(0); _fnDisplay(0);
|
||||||
gSim.fit();
|
gSim.fit();
|
||||||
gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
|
gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
|
||||||
return;
|
return;
|
||||||
@@ -572,19 +666,83 @@ class GraphSim {
|
|||||||
.replace(/\*/g, '\\cdot ');
|
.replace(/\*/g, '\\cdot ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _katexInto(el, latex) {
|
||||||
|
try { el.innerHTML = katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }); return true; }
|
||||||
|
catch { el.innerHTML = ''; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Живое превью формулы под полем — показывается ТОЛЬКО пока строка в правке. */
|
||||||
function renderPreview(idx) {
|
function renderPreview(idx) {
|
||||||
const inp = document.getElementById('fn' + idx);
|
const inp = document.getElementById('fn' + idx);
|
||||||
const prev = document.getElementById('fn' + idx + '-prev');
|
const prev = document.getElementById('fn' + idx + '-prev');
|
||||||
|
if (!prev) return;
|
||||||
const raw = inp?.value?.trim() || '';
|
const raw = inp?.value?.trim() || '';
|
||||||
if (!raw || typeof katex === 'undefined') {
|
if (!raw || typeof katex === 'undefined') { prev.innerHTML = ''; prev.classList.remove('has-content'); return; }
|
||||||
prev.innerHTML = ''; prev.classList.remove('has-content'); return;
|
if (_katexInto(prev, toLatex(raw))) prev.classList.add('has-content');
|
||||||
}
|
else prev.classList.remove('has-content');
|
||||||
try {
|
}
|
||||||
prev.innerHTML = katex.renderToString(toLatex(raw), {
|
|
||||||
throwOnError: false, strict: false, displayMode: false,
|
/* Введённая функция — отрисованной формулой KaTeX прямо в строке. */
|
||||||
|
function renderFnMath(idx) {
|
||||||
|
const inp = document.getElementById('fn' + idx);
|
||||||
|
const m = document.getElementById('fn' + idx + '-math');
|
||||||
|
if (!m || !inp) return;
|
||||||
|
const raw = inp.value.trim();
|
||||||
|
if (!raw || typeof katex === 'undefined') { m.innerHTML = ''; return; }
|
||||||
|
_katexInto(m, toLatex(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Режим строки: не в фокусе и есть формула → показываем KaTeX; иначе — поле ввода. */
|
||||||
|
function _fnDisplay(idx) {
|
||||||
|
const inp = document.getElementById('fn' + idx);
|
||||||
|
const field = inp && inp.closest('.fn-field');
|
||||||
|
if (!field) return;
|
||||||
|
const showMath = (document.activeElement !== inp) && !!inp.value.trim() && typeof katex !== 'undefined';
|
||||||
|
if (showMath) { renderFnMath(idx); field.classList.add('has-math'); }
|
||||||
|
else field.classList.remove('has-math');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вставка структуры формулы в активное поле (как редактор формул).
|
||||||
|
В токене символ | помечает позицию каретки, напр. 'sin(|)'. */
|
||||||
|
let _activeFnIdx = 0, _graphPanelInit = false;
|
||||||
|
function graphInsert(token) {
|
||||||
|
const el = document.getElementById('fn' + _activeFnIdx) || document.getElementById('fn0');
|
||||||
|
if (!el) return;
|
||||||
|
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math'); // в режим правки
|
||||||
|
el.focus();
|
||||||
|
let ins = String(token || ''); let caretInTok = -1;
|
||||||
|
const bar = ins.indexOf('|');
|
||||||
|
if (bar >= 0) { caretInTok = bar; ins = ins.slice(0, bar) + ins.slice(bar + 1); }
|
||||||
|
const s = (el.selectionStart != null) ? el.selectionStart : el.value.length;
|
||||||
|
const e = (el.selectionEnd != null) ? el.selectionEnd : el.value.length;
|
||||||
|
el.value = el.value.slice(0, s) + ins + el.value.slice(e);
|
||||||
|
const pos = s + (caretInTok >= 0 ? caretInTok : ins.length);
|
||||||
|
try { el.setSelectionRange(pos, pos); } catch (_) {}
|
||||||
|
updateFn(_activeFnIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KaTeX на чипах/клавиатуре + math-поля + слежение за активным полем (идемпотентно). */
|
||||||
|
function _initGraphPanel() {
|
||||||
|
const root = document.getElementById('sim-graph');
|
||||||
|
if (!root || typeof katex === 'undefined') return;
|
||||||
|
root.querySelectorAll('.preset-btn[data-tex], .kp-btn[data-tex]').forEach(b => {
|
||||||
|
if (b.dataset.rendered) return;
|
||||||
|
if (_katexInto(b, b.dataset.tex)) b.dataset.rendered = '1';
|
||||||
|
});
|
||||||
|
if (!_graphPanelInit) {
|
||||||
|
_graphPanelInit = true;
|
||||||
|
[0, 1, 2].forEach(i => {
|
||||||
|
const el = document.getElementById('fn' + i);
|
||||||
|
const m = document.getElementById('fn' + i + '-math');
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('focus', () => { _activeFnIdx = i; _fnDisplay(i); });
|
||||||
|
el.addEventListener('blur', () => { _fnDisplay(i); });
|
||||||
|
}
|
||||||
|
// клик по формуле → правка текста на месте
|
||||||
|
if (m) m.addEventListener('mousedown', (ev) => { ev.preventDefault(); const f = m.closest('.fn-field'); if (f) f.classList.remove('has-math'); el && el.focus(); });
|
||||||
});
|
});
|
||||||
prev.classList.add('has-content');
|
}
|
||||||
} catch { prev.innerHTML = ''; prev.classList.remove('has-content'); }
|
[0, 1, 2].forEach(i => { renderPreview(i); _fnDisplay(i); });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* debounced formula update */
|
/* debounced formula update */
|
||||||
@@ -623,11 +781,43 @@ class GraphSim {
|
|||||||
document.getElementById('fn' + i).value = '';
|
document.getElementById('fn' + i).value = '';
|
||||||
document.getElementById('fn' + i + '-prev').innerHTML = '';
|
document.getElementById('fn' + i + '-prev').innerHTML = '';
|
||||||
document.getElementById('fn' + i + '-prev').classList.remove('has-content');
|
document.getElementById('fn' + i + '-prev').classList.remove('has-content');
|
||||||
|
const m = document.getElementById('fn' + i + '-math'); if (m) m.innerHTML = '';
|
||||||
|
const f = document.getElementById('fn' + i)?.closest('.fn-field'); if (f) f.classList.remove('has-math');
|
||||||
document.getElementById('fn' + i + '-err').classList.remove('show');
|
document.getElementById('fn' + i + '-err').classList.remove('show');
|
||||||
if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
|
if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── per-function controls + view controls ── */
|
||||||
|
const _EYE_ON = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
|
const _EYE_OFF = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-3 3.7M6.6 6.6A17 17 0 0 0 2 11s3.5 7 10 7a10 10 0 0 0 4.1-.9"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
|
||||||
|
|
||||||
|
function toggleFn(idx) {
|
||||||
|
if (!gSim) return;
|
||||||
|
const hidden = !gSim._hidden[idx];
|
||||||
|
gSim.setHidden(idx, hidden);
|
||||||
|
const row = document.getElementById('fn' + idx)?.closest('.fn-row');
|
||||||
|
if (row) row.classList.toggle('fn-hidden', hidden);
|
||||||
|
const eye = document.getElementById('fn' + idx + '-eye');
|
||||||
|
if (eye) { eye.innerHTML = hidden ? _EYE_OFF : _EYE_ON; eye.classList.toggle('off', hidden); }
|
||||||
|
}
|
||||||
|
function clearFn(idx) {
|
||||||
|
const el = document.getElementById('fn' + idx); if (!el) return;
|
||||||
|
el.value = ''; updateFn(idx);
|
||||||
|
const m = document.getElementById('fn' + idx + '-math'); if (m) m.innerHTML = '';
|
||||||
|
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math');
|
||||||
|
// снять скрытие, если было
|
||||||
|
if (gSim && gSim._hidden[idx]) toggleFn(idx);
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
function graphZoom(dir) { if (gSim) { dir > 0 ? gSim.zoomIn() : gSim.zoomOut(); } }
|
||||||
|
function graphFit() { if (gSim) gSim.resetView(); }
|
||||||
|
function toggleGraphPoints() {
|
||||||
|
if (!gSim) return;
|
||||||
|
const on = !gSim.showPts; gSim.setShowPoints(on);
|
||||||
|
const b = document.getElementById('graph-pts-btn'); if (b) b.classList.toggle('active', on);
|
||||||
|
}
|
||||||
|
|
||||||
/* hover info bar */
|
/* hover info bar */
|
||||||
function fmtVal(v) {
|
function fmtVal(v) {
|
||||||
if (v === null || v === undefined) return '—';
|
if (v === null || v === undefined) return '—';
|
||||||
|
|||||||
+32
-14
@@ -1252,10 +1252,16 @@ class OrganicSim {
|
|||||||
center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative';
|
center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative';
|
||||||
panel.appendChild(center);
|
panel.appendChild(center);
|
||||||
|
|
||||||
|
// wrapper makes the canvas position:absolute so its intrinsic pixel size
|
||||||
|
// (set in _drawQual) can't feed back into the flex layout and inflate height
|
||||||
|
const canvasWrap = document.createElement('div');
|
||||||
|
canvasWrap.style.cssText = 'flex:1;position:relative;overflow:hidden';
|
||||||
|
center.appendChild(canvasWrap);
|
||||||
|
|
||||||
const qualCanvas = document.createElement('canvas');
|
const qualCanvas = document.createElement('canvas');
|
||||||
qualCanvas.style.cssText = 'width:100%;flex:1;display:block';
|
qualCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
|
||||||
this._qualCanvas = qualCanvas;
|
this._qualCanvas = qualCanvas;
|
||||||
center.appendChild(qualCanvas);
|
canvasWrap.appendChild(qualCanvas);
|
||||||
|
|
||||||
// compounds area
|
// compounds area
|
||||||
const compArea = document.createElement('div');
|
const compArea = document.createElement('div');
|
||||||
@@ -1376,15 +1382,21 @@ class OrganicSim {
|
|||||||
const comp = this._qualCompound;
|
const comp = this._qualCompound;
|
||||||
const anim = this._qualAnim;
|
const anim = this._qualAnim;
|
||||||
|
|
||||||
// draw multiple test tubes
|
// draw multiple test tubes — sized to fill the available height (bigger flasks)
|
||||||
const tubes = rxn.compounds;
|
const tubes = rxn.compounds;
|
||||||
const tubeW = 56, tubeH = 150, gap = 20;
|
const badgePad = 48; // room above tubes for the +/−/? badge
|
||||||
|
const labelPad = 62; // room below tubes for the name + reagent labels
|
||||||
|
const tubeH = Math.max(190, Math.min(H - badgePad - labelPad, 340));
|
||||||
|
const tubeW = Math.round(tubeH * 0.46);
|
||||||
|
const gap = Math.round(tubeW * 0.6);
|
||||||
const totalW = tubes.length * (tubeW + gap) - gap;
|
const totalW = tubes.length * (tubeW + gap) - gap;
|
||||||
let startX = (W - totalW) / 2;
|
const startX = (W - totalW) / 2;
|
||||||
|
// centre vertically, but never push the tubes so low they leave the viewport
|
||||||
|
let ty = badgePad + Math.max(0, (H - badgePad - labelPad - tubeH) / 2);
|
||||||
|
ty = Math.min(ty, 210);
|
||||||
|
|
||||||
tubes.forEach((tube, i) => {
|
tubes.forEach((tube, i) => {
|
||||||
const tx = startX + i * (tubeW + gap);
|
const tx = startX + i * (tubeW + gap);
|
||||||
const ty = (H - tubeH) / 2 - 10;
|
|
||||||
const isActive = comp && comp === tube;
|
const isActive = comp && comp === tube;
|
||||||
const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0;
|
const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0;
|
||||||
|
|
||||||
@@ -1392,19 +1404,19 @@ class OrganicSim {
|
|||||||
|
|
||||||
// label
|
// label
|
||||||
ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)';
|
ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)';
|
||||||
ctx.font = `${isActive ? '700' : '400'} 10px Manrope,sans-serif`;
|
ctx.font = `${isActive ? '700' : '400'} 11px Manrope,sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
const label = tube.name.length > 12 ? tube.name.substring(0,11)+'…' : tube.name;
|
const label = tube.name.length > 14 ? tube.name.substring(0,13)+'…' : tube.name;
|
||||||
ctx.fillText(label, tx + tubeW/2, ty + tubeH + 8);
|
ctx.fillText(label, tx + tubeW/2, ty + tubeH + 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
// reagent label
|
// reagent label
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||||
ctx.font = '11px Manrope,sans-serif';
|
ctx.font = '12px Manrope,sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, H - 4);
|
ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, Math.min(H - 6, ty + tubeH + 44));
|
||||||
}
|
}
|
||||||
|
|
||||||
_drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) {
|
_drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) {
|
||||||
@@ -1425,10 +1437,16 @@ class OrganicSim {
|
|||||||
ctx.lineTo(x + w - 4, y);
|
ctx.lineTo(x + w - 4, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// clip to tube for liquid
|
// clip to the tube interior (straight sides + rounded bottom) so the
|
||||||
|
// liquid never spills past the glass outline
|
||||||
|
const rBot = w/2 - 4;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(x + 4, liqY, w - 8, liqH - 8);
|
ctx.moveTo(x + 4, liqY);
|
||||||
ctx.arc(x + w/2, y + h - (w/2 - 4), w/2 - 4, 0, Math.PI);
|
ctx.lineTo(x + 4, y + h - rBot);
|
||||||
|
ctx.arcTo(x + 4, y + h, x + w/2, y + h, rBot);
|
||||||
|
ctx.arcTo(x + w - 4, y + h, x + w - 4, y + h - rBot, rBot);
|
||||||
|
ctx.lineTo(x + w - 4, liqY);
|
||||||
|
ctx.closePath();
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
// base liquid (reagent color)
|
// base liquid (reagent color)
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ class RedoxSim {
|
|||||||
ctx.fillText(s.lbl, 14, y);
|
ctx.fillText(s.lbl, 14, y);
|
||||||
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
|
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
|
||||||
ctx.font = '9.5px monospace';
|
ctx.font = '9.5px monospace';
|
||||||
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
|
ctx.fillText(ChemVisuals.cleanIcons(s.txt), 14 + ctx.measureText(s.lbl).width + 8, y);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+416
-65
@@ -53,6 +53,8 @@ class TrigCircleSim {
|
|||||||
this.graphFn = 'sin';
|
this.graphFn = 'sin';
|
||||||
this.snapToNotable = true;
|
this.snapToNotable = true;
|
||||||
this.animating = false;
|
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._cx = 0; this._cy = 0; this._r = 0;
|
||||||
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
|
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
|
||||||
@@ -96,11 +98,14 @@ class TrigCircleSim {
|
|||||||
|
|
||||||
this._drawBg(c);
|
this._drawBg(c);
|
||||||
this._drawCircle(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); }
|
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
|
||||||
this._drawParticles(c);
|
this._drawParticles(c);
|
||||||
if (window.LabFX) LabFX.particles.draw(c);
|
if (window.LabFX) LabFX.particles.draw(c);
|
||||||
|
|
||||||
c.restore();
|
c.restore();
|
||||||
|
this._ovClearUnused();
|
||||||
this._fireUpdate();
|
this._fireUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +121,103 @@ class TrigCircleSim {
|
|||||||
this._layout(); this.draw();
|
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) {
|
||||||
|
// Любая LaTeX-команда (\pi, \tfrac, \sin…) → KaTeX; простой текст/число — быстро текстом.
|
||||||
|
const useK = /\\/.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) {
|
goToAngle(rad) {
|
||||||
this._animTarget = this._norm(rad);
|
this._animTarget = this._norm(rad);
|
||||||
if (!this.animating) this._startAnim();
|
if (!this.animating) this._startAnim();
|
||||||
@@ -130,7 +232,16 @@ class TrigCircleSim {
|
|||||||
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
|
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
|
||||||
const deg = a * 180 / Math.PI;
|
const deg = a * 180 / Math.PI;
|
||||||
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
|
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 ═══════════════════════════════════════════════════════ */
|
/* ═══ Layout ═══════════════════════════════════════════════════════ */
|
||||||
@@ -290,11 +401,10 @@ class TrigCircleSim {
|
|||||||
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
|
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
|
||||||
c.strokeStyle = ag; c.lineWidth = 2.5;
|
c.strokeStyle = ag; c.lineWidth = 2.5;
|
||||||
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
|
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
|
||||||
/* label */
|
/* label (KaTeX overlay: π-доля для табличных, иначе текст) */
|
||||||
const mid = a / 2, lr = ar + 18;
|
const mid = a / 2, lr = ar + 20;
|
||||||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
|
||||||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
|
||||||
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── radius ── */
|
/* ── radius ── */
|
||||||
@@ -388,9 +498,9 @@ class TrigCircleSim {
|
|||||||
|
|
||||||
/* ── axis value badges ── */
|
/* ── axis value badges ── */
|
||||||
if (this.showSin && Math.abs(sinA) > 0.04)
|
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)
|
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 ── */
|
/* ── main point ── */
|
||||||
const ps = this._hover || this._drag ? 10 : 8;
|
const ps = this._hover || this._drag ? 10 : 8;
|
||||||
@@ -406,8 +516,12 @@ class TrigCircleSim {
|
|||||||
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
|
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
|
||||||
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
|
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
|
||||||
|
|
||||||
/* ── coordinate tooltip ── */
|
/* ── coordinate tooltip (KaTeX overlay) — выносим РАДИАЛЬНО НАРУЖУ за точку,
|
||||||
this._tooltip(c, px, py, cosA, sinA);
|
чтобы не перекрывать центральную дугу угла и её подпись ── */
|
||||||
|
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 ── */
|
/* ── quadrant roman numeral ── */
|
||||||
const qOff = r * 0.46;
|
const qOff = r * 0.46;
|
||||||
@@ -538,7 +652,6 @@ class TrigCircleSim {
|
|||||||
|
|
||||||
const fn = this.graphFn;
|
const fn = this.graphFn;
|
||||||
const col = _TC[fn] || _TC.sin;
|
const col = _TC[fn] || _TC.sin;
|
||||||
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
|
|
||||||
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
|
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
|
||||||
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
|
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
|
||||||
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
|
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
|
||||||
@@ -575,29 +688,30 @@ class TrigCircleSim {
|
|||||||
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
|
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ±1 lines */
|
/* ── шкала значений по оси Y (значения на координатной плоскости) ── */
|
||||||
if (fn==='sin'||fn==='cos') {
|
const yVals = (fn==='tan'||fn==='cot')
|
||||||
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
|
? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
|
||||||
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
|
: [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
|
||||||
c.setLineDash([]);
|
yVals.forEach(([v, lx], i) => {
|
||||||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
|
const yy = sy(v);
|
||||||
c.textAlign='right'; c.textBaseline='middle';
|
if (yy < gy + 6 || yy > gy + gh - 6) return;
|
||||||
c.fillText('1', gx-5, sy(1)); c.fillText('−1', gx-5, sy(-1));
|
if (v !== 0) {
|
||||||
}
|
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.lineWidth = 1; c.setLineDash([4, 4]);
|
||||||
|
c.beginPath(); c.moveTo(gx, yy); c.lineTo(gx+gw, yy); c.stroke(); c.setLineDash([]);
|
||||||
|
}
|
||||||
|
this._ovLabel('gy' + i, lx, gx - 6, yy, 'rgba(255,255,255,0.55)', 'r');
|
||||||
|
});
|
||||||
|
|
||||||
/* x ticks */
|
/* x ticks — линии на canvas, подписи KaTeX-оверлеем */
|
||||||
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
|
const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
|
||||||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
|
[3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
|
||||||
c.textAlign='center'; c.textBaseline='top';
|
ticks.forEach(([v, lx], i) => {
|
||||||
for (const [v,l] of ticks) {
|
|
||||||
const xx = sx(v);
|
const xx = sx(v);
|
||||||
if (xx < gx+6 || xx > gx+gw-6) continue;
|
if (xx < gx+6 || xx > gx+gw-6) return;
|
||||||
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
|
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.setLineDash([3,3]);
|
||||||
c.setLineDash([3,3]);
|
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); c.setLineDash([]);
|
||||||
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
|
this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
|
||||||
c.setLineDash([]);
|
});
|
||||||
c.fillText(l, xx, gy+gh+6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ghost curves (other functions, dimmed) ── */
|
/* ── ghost curves (other functions, dimmed) ── */
|
||||||
c.save();
|
c.save();
|
||||||
@@ -669,6 +783,21 @@ class TrigCircleSim {
|
|||||||
}
|
}
|
||||||
c.stroke();
|
c.stroke();
|
||||||
|
|
||||||
|
/* ── развёртка: ярче выделяем кривую на [0, α] — как угол «разворачивается» в график ── */
|
||||||
|
{
|
||||||
|
const aMax = Math.min(Math.max(this.angle, 0), xMax);
|
||||||
|
c.strokeStyle = col; c.lineWidth = 4.5; c.lineCap = 'round'; c.lineJoin = 'round';
|
||||||
|
c.shadowColor = col; c.shadowBlur = 6;
|
||||||
|
c.beginPath(); let onS = false;
|
||||||
|
for (let x = 0; x <= aMax + 1e-9; x += step) {
|
||||||
|
const yv = evFn(x);
|
||||||
|
if (!isFinite(yv) || Math.abs(yv) > yR * 2) { onS = false; continue; }
|
||||||
|
const spx = sx(x), spy = sy(yv);
|
||||||
|
if (!onS) { c.moveTo(spx, spy); onS = true; } else c.lineTo(spx, spy);
|
||||||
|
}
|
||||||
|
c.stroke(); c.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── current angle marker ── */
|
/* ── current angle marker ── */
|
||||||
const curY = evFn(this.angle);
|
const curY = evFn(this.angle);
|
||||||
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
|
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
|
||||||
@@ -687,31 +816,21 @@ class TrigCircleSim {
|
|||||||
c.shadowBlur = 0;
|
c.shadowBlur = 0;
|
||||||
c.fillStyle = 'rgba(255,255,255,0.7)';
|
c.fillStyle = 'rgba(255,255,255,0.7)';
|
||||||
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
|
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
|
||||||
/* value badge */
|
/* value badge (KaTeX overlay) */
|
||||||
const txt = this._fmt(curY);
|
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
|
||||||
c.font = 'bold 11px Manrope,sans-serif';
|
/* подпись угла на оси X (развёртка: где текущий угол на графике) */
|
||||||
const tm = c.measureText(txt);
|
this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
|
||||||
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
|
mx, gy + 5, _TC.violet, 't', true);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.restore();
|
c.restore();
|
||||||
|
|
||||||
/* fn name badge */
|
/* fn name badge (KaTeX-оверлей) */
|
||||||
c.font='bold 13px Manrope,sans-serif';
|
const _glblTex = fn === 'sin' ? 'y = \\sin x'
|
||||||
const tm2 = c.measureText(lbl);
|
: fn === 'cos' ? 'y = \\cos x'
|
||||||
const bw3 = tm2.width+18, bh3 = 26;
|
: fn === 'tan' ? 'y = \\operatorname{tg} x'
|
||||||
c.fillStyle='rgba(12,12,22,0.7)';
|
: 'y = \\operatorname{ctg} x';
|
||||||
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
|
this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
|
||||||
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
|
|
||||||
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
|
|
||||||
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
|
|
||||||
c.fillText(lbl, gx+17, gy+21);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ Snap particles ═══════════════════════════════════════════════ */
|
/* ═══ Snap particles ═══════════════════════════════════════════════ */
|
||||||
@@ -1029,6 +1148,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
|||||||
if (window.LabFX) LabFX.sound.play('click');
|
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) {
|
function _trigUpdateUI(s) {
|
||||||
const _f = v => {
|
const _f = v => {
|
||||||
if (v === undefined) return '—';
|
if (v === undefined) return '—';
|
||||||
@@ -1044,25 +1301,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
|||||||
};
|
};
|
||||||
const degStr = s.deg.toFixed(1) + '°';
|
const degStr = s.deg.toFixed(1) + '°';
|
||||||
|
|
||||||
// Panel values (nice fractions)
|
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
|
||||||
document.getElementById('trig-v-sin').textContent = _f(s.sin);
|
const setMathVal = (id, v) => {
|
||||||
document.getElementById('trig-v-cos').textContent = _f(s.cos);
|
const el = document.getElementById(id); if (!el) return;
|
||||||
document.getElementById('trig-v-tan').textContent = _f(s.tan);
|
const lx = _latexVal(v);
|
||||||
document.getElementById('trig-v-cot').textContent = _f(s.cot);
|
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 =
|
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-angle').textContent = degStr;
|
||||||
document.getElementById('trigbar-sin').textContent = _f(s.sin);
|
setMathVal('trigbar-sin', s.sin);
|
||||||
document.getElementById('trigbar-cos').textContent = _f(s.cos);
|
setMathVal('trigbar-cos', s.cos);
|
||||||
document.getElementById('trigbar-tan').textContent = _f(s.tan);
|
setMathVal('trigbar-tan', s.tan);
|
||||||
document.getElementById('trigbar-cot').textContent = _f(s.cot);
|
setMathVal('trigbar-cot', s.cot);
|
||||||
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
|
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 ── */
|
/* ── 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 */
|
/** 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 */
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
TrainerEngine — ядро ИИ-тренажёра (Фаза 0, прототип).
|
||||||
|
|
||||||
|
Идея (гибрид): задачи рождаются из ДАННЫХ — «генераторов», а математика
|
||||||
|
считается ДЕТЕРМИНИРОВАННО через SimExpr (тот же безопасный вычислитель, что
|
||||||
|
у конструктора симуляций; ⛔ без eval/new Function). LLM в этом ядре НЕ
|
||||||
|
участвует: его роль — один раз сочинить генераторы (Уровень 0) либо позже
|
||||||
|
отдавать текстовые задачи, которые ЭТОТ ЖЕ слой верифицирует подстановкой
|
||||||
|
(Уровень 1). Любой источник задачи проходит один и тот же verifyRoot.
|
||||||
|
|
||||||
|
Генератор (данные):
|
||||||
|
{
|
||||||
|
id, skill, title,
|
||||||
|
pick: { a:[lo,hi], ... }, // целые параметры из диапазонов
|
||||||
|
constraint?: "c < a", // булево над pick (SimExpr) — иначе пересэмпл
|
||||||
|
derive?: { c: "a*root + b" }, // доп. параметры последовательно (SimExpr)
|
||||||
|
require?: "...", // булево после derive — иначе пересэмпл
|
||||||
|
lhs, rhs, // СТОРОНЫ уравнения как выражения с {param} и x
|
||||||
|
display?, // как показать (по умолч. "lhs = rhs")
|
||||||
|
answerVar?: "x", // имя неизвестной (деф. x)
|
||||||
|
answer: "root", // корень как формула над параметрами
|
||||||
|
integerAnswer?: true, // требовать целый корень
|
||||||
|
solution?: ["шаг … {ans}", …] // шаблоны шагов (доступен {ans})
|
||||||
|
}
|
||||||
|
|
||||||
|
Гарантия КОРРЕКТНОСТИ: после материализации движок ПОДСТАВЛЯЕТ заявленный
|
||||||
|
корень в уравнение (verifyRoot). Не сходится — экземпляр отбрасывается (в
|
||||||
|
strict-режиме — исключение). Та же подстановка проверяет ответ ученика
|
||||||
|
(checkStudentAnswer) и автоматически принимает эквивалентные формы
|
||||||
|
(5, 5.0, 10/2, "x=15/3", "2+3").
|
||||||
|
|
||||||
|
API (window.TrainerEngine):
|
||||||
|
instantiate(gen, opts) -> problem | null
|
||||||
|
generateBatch(gen, n, opts) -> problem[]
|
||||||
|
verifyRoot(problem, value) -> { ok, residual, lhs, rhs }
|
||||||
|
checkStudentAnswer(problem, input)-> { ok, value, residual, message, reason? }
|
||||||
|
makeRng(seed) -> () => [0,1) (детерминизм для тестов/пула)
|
||||||
|
|
||||||
|
problem:
|
||||||
|
{ genId, skill, title, lhsExpr, rhsExpr, display, answerVar, answer,
|
||||||
|
params, solution }
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
|
||||||
|
function SE() {
|
||||||
|
var s = global.SimExpr;
|
||||||
|
if (!s) throw new Error('TrainerEngine требует SimExpr (подключите _sim_expr.js раньше).');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Допуск подстановки: масштабируется величиной сторон, чтобы крупные
|
||||||
|
// коэффициенты не давали ложного «не сходится» из-за плавающей арифметики.
|
||||||
|
var EPS = 1e-7;
|
||||||
|
|
||||||
|
/* ── Детерминированный ГПСЧ (mulberry32) — тот же, что в game/map.js ──
|
||||||
|
Нужен, чтобы предгенерация пула и тесты были воспроизводимы. В рантайме
|
||||||
|
можно не передавать seed (тогда берётся внутренний инкремент от Date нельзя —
|
||||||
|
поэтому дефолт фиксирован, а вариативность даёт сам диапазон pick). */
|
||||||
|
function makeRng(seed) {
|
||||||
|
var s = (seed >>> 0) || 1;
|
||||||
|
return function () {
|
||||||
|
s |= 0; s = (s + 0x6D2B79F5) | 0;
|
||||||
|
var t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); }
|
||||||
|
|
||||||
|
/* Разложение числа на простые множители как строка «2*2*3» (без степеней, по
|
||||||
|
возрастанию). Для отображения в шагах решения (НОД/НОК и т.п.). */
|
||||||
|
function primeFactorString(n) {
|
||||||
|
n = Math.abs(Math.round(n));
|
||||||
|
if (!isFinite(n) || n < 2) return String(n || 0);
|
||||||
|
var fs = [], d = 2;
|
||||||
|
while (d * d <= n) { while (n % d === 0) { fs.push(d); n = n / d; } d++; }
|
||||||
|
if (n > 1) fs.push(n);
|
||||||
|
return fs.join('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Уровни сложности: масштабирование диапазона pick ──
|
||||||
|
level 2 — базовый (как задано); 1 — легче (меньше магнитуды, меньше
|
||||||
|
отрицательных); 3 — сложнее (шире магнитуды). Универсально для всех
|
||||||
|
генераторов; корректность держит «корень-вперёд» + самопроверка. */
|
||||||
|
function _scaleRange(r, level) {
|
||||||
|
var lo = r[0], hi = r[1];
|
||||||
|
if (!level || level === 2) return [lo, hi];
|
||||||
|
if (level === 1) {
|
||||||
|
var nlo = lo < 0 ? Math.ceil(lo / 2) : lo;
|
||||||
|
var nhi = hi > 0 ? Math.max(nlo + 1, Math.round(hi / 2)) : hi;
|
||||||
|
return [nlo, nhi];
|
||||||
|
}
|
||||||
|
var elo = lo < 0 ? Math.floor(lo * 1.8) : lo;
|
||||||
|
var ehi = Math.round(hi * 1.8);
|
||||||
|
if (ehi <= elo) ehi = elo + 1;
|
||||||
|
return [elo, ehi];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */
|
||||||
|
var _cache = Object.create(null);
|
||||||
|
function compileExpr(src) {
|
||||||
|
var key = String(src);
|
||||||
|
var c = _cache[key];
|
||||||
|
if (!c) { c = SE().compile(key); _cache[key] = c; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
function evalExpr(src, env) { return compileExpr(src).fn(env); }
|
||||||
|
|
||||||
|
function truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
|
||||||
|
function isIntApprox(v) { return isFinite(v) && Math.abs(v - Math.round(v)) < 1e-9; }
|
||||||
|
|
||||||
|
function fmtNum(v) {
|
||||||
|
if (typeof v !== 'number') return String(v);
|
||||||
|
if (isIntApprox(v)) return String(Math.round(v));
|
||||||
|
return String(Math.round(v * 1e6) / 1e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подстановка {name} -> значение (для выражений и подписей). */
|
||||||
|
function render(tpl, vals) {
|
||||||
|
return String(tpl).replace(/\{(\w+)\}/g, function (m, k) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(vals, k) ? fmtNum(vals[k]) : m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Лёгкая косметика ТОЛЬКО для показа (не для вычислений):
|
||||||
|
5*x -> 5x, «+ -» -> «− », ведущий коэффициент 1 у x убираем. */
|
||||||
|
function prettyMath(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/(\d)\s*\*\s*(\d)/g, '$1·$2') // 4*5 -> 4·5 (число·число)
|
||||||
|
.replace(/\s*\*\s*/g, '') // 7*x -> 7x (неявное умножение)
|
||||||
|
.replace(/\+\s*-\s*/g, '− ') // + -3 -> − 3
|
||||||
|
.replace(/-\s*-\s*/g, '+ ')
|
||||||
|
.replace(/(^|[(=+\-\s])1(?=x)/g, '$1'); // ведущий 1·x -> x
|
||||||
|
}
|
||||||
|
|
||||||
|
function assign(base, extra) {
|
||||||
|
var o = {}, k;
|
||||||
|
for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k];
|
||||||
|
for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) o[k] = extra[k];
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Выражение -> LaTeX (через AST SimExpr) для KaTeX-рендера ──
|
||||||
|
Возвращает строку LaTeX или null, если выражение не разобралось. Покрывает
|
||||||
|
наши нужды: дроби (\frac), степени, неявное умножение, скобки по приоритету,
|
||||||
|
сравнения (= ≠ ≤ ≥), sqrt/abs/тригонометрию. Один проход AST, без eval.
|
||||||
|
Reusable: тем же конвертером можно рендерить и задачи Уровня-1 (LLM). */
|
||||||
|
function _prec(n) {
|
||||||
|
if (!n) return 9;
|
||||||
|
if (n.k === 'cmp' || n.k === 'logic') return 0;
|
||||||
|
if (n.k === 'bin') {
|
||||||
|
if (n.op === '+' || n.op === '-') return 1;
|
||||||
|
if (n.op === '*' || n.op === '/' || n.op === '%') return 2;
|
||||||
|
if (n.op === '^') return 4;
|
||||||
|
}
|
||||||
|
if (n.k === 'un' || n.k === 'not') return 3;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
function _isNeg(n) {
|
||||||
|
return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-') ||
|
||||||
|
(n.k === 'bin' && n.op === '*' && _isNeg(n.a)); // (-5)*x — отрицательное слагаемое
|
||||||
|
}
|
||||||
|
function _negate(n) {
|
||||||
|
if (n.k === 'num') return { k: 'num', v: -n.v };
|
||||||
|
if (n.k === 'un' && n.op === '-') return n.a;
|
||||||
|
if (n.k === 'bin' && n.op === '*') return { k: 'bin', op: '*', a: _negate(n.a), b: n.b };
|
||||||
|
return { k: 'un', op: '-', a: n };
|
||||||
|
}
|
||||||
|
function _wrapL(node, minPrec) {
|
||||||
|
var s = _latex(node);
|
||||||
|
return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s;
|
||||||
|
}
|
||||||
|
// «числовой множитель»: число, константа или степень с числовым основанием (2, 7^2).
|
||||||
|
// Между двумя такими ставим ·, иначе адъяцентность «2·7²» прочитается как «27²».
|
||||||
|
function _isNumFactor(n) {
|
||||||
|
return n.k === 'num' || n.k === 'const' || (n.k === 'bin' && n.op === '^' && _isNumFactor(n.a));
|
||||||
|
}
|
||||||
|
// Операнд умножения: отрицательное/унарное/сумму берём в скобки, иначе
|
||||||
|
// соседство схлопнет смысл (7*(-5) -> «7-5», 6*(x+1) -> «6x+1»).
|
||||||
|
function _mulOperand(node) {
|
||||||
|
if (_isNeg(node) || _prec(node) < 2) return '\\left(' + _latex(node) + '\\right)';
|
||||||
|
return _latex(node);
|
||||||
|
}
|
||||||
|
function _latex(node) {
|
||||||
|
switch (node.k) {
|
||||||
|
case 'num': return fmtNum(node.v);
|
||||||
|
case 'const':
|
||||||
|
if (node.v === Math.PI) return '\\pi';
|
||||||
|
if (node.v === Math.PI * 2) return '\\tau';
|
||||||
|
if (node.v === Math.E) return 'e';
|
||||||
|
return fmtNum(node.v);
|
||||||
|
case 'var': return node.name;
|
||||||
|
case 'un': return '-' + _wrapL(node.a, 3);
|
||||||
|
case 'not': return '\\lnot ' + _wrapL(node.a, 3);
|
||||||
|
case 'cmp': {
|
||||||
|
var m = { '==': '=', '!=': '\\ne', '<': '<', '<=': '\\le', '>': '>', '>=': '\\ge' };
|
||||||
|
return _latex(node.a) + ' ' + (m[node.op] || node.op) + ' ' + _latex(node.b);
|
||||||
|
}
|
||||||
|
case 'logic':
|
||||||
|
return _latex(node.a) + (node.op === '&&' ? ' \\land ' : ' \\lor ') + _latex(node.b);
|
||||||
|
case 'cond':
|
||||||
|
return _wrapL(node.c, 1) + ' \\,?\\, ' + _latex(node.a) + ' : ' + _latex(node.b);
|
||||||
|
case 'call': {
|
||||||
|
if (node.name === 'sqrt') return '\\sqrt{' + _latex(node.args[0]) + '}';
|
||||||
|
if (node.name === 'abs') return '\\left|' + _latex(node.args[0]) + '\\right|';
|
||||||
|
var TRIG = { sin: '\\sin', cos: '\\cos', tan: '\\tan', tg: '\\tan', ln: '\\ln', log: '\\log', exp: '\\exp' };
|
||||||
|
var fn = TRIG[node.name] || ('\\operatorname{' + node.name + '}');
|
||||||
|
return fn + '\\left(' + node.args.map(_latex).join(',\\, ') + '\\right)';
|
||||||
|
}
|
||||||
|
case 'bin': {
|
||||||
|
var op = node.op;
|
||||||
|
if (op === '/') return '\\frac{' + _latex(node.a) + '}{' + _latex(node.b) + '}';
|
||||||
|
if (op === '^') {
|
||||||
|
var base = _prec(node.a) < 5 ? '\\left(' + _latex(node.a) + '\\right)' : _latex(node.a);
|
||||||
|
if (node.b.k === 'num' && node.b.v === 1) return base; // x^1 -> x
|
||||||
|
if (node.b.k === 'num' && node.b.v === 0) return '1'; // x^0 -> 1
|
||||||
|
return base + '^{' + _latex(node.b) + '}';
|
||||||
|
}
|
||||||
|
if (op === '*') {
|
||||||
|
// единичный коэффициент: 1*x -> x, (-1)*x -> -x (только при не-числовом множителе)
|
||||||
|
if (node.a.k === 'num' && Math.abs(node.a.v) === 1 && node.b.k !== 'num')
|
||||||
|
return (node.a.v < 0 ? '-' : '') + _mulOperand(node.b);
|
||||||
|
if (node.b.k === 'num' && Math.abs(node.b.v) === 1 && node.a.k !== 'num')
|
||||||
|
return (node.b.v < 0 ? '-' : '') + _mulOperand(node.a);
|
||||||
|
if (_isNeg(node.a)) return '-' + _latex({ k: 'bin', op: '*', a: _negate(node.a), b: node.b }); // -5*x -> «-5x»
|
||||||
|
// · между числами и числовыми множителями (2·3, 2·7²); иначе соседство (2x, 3(x+1))
|
||||||
|
var sep = ((node.b.k === 'num' && node.b.v >= 0) || (_isNumFactor(node.a) && _isNumFactor(node.b))) ? ' \\cdot ' : '';
|
||||||
|
return _mulOperand(node.a) + sep + _mulOperand(node.b);
|
||||||
|
}
|
||||||
|
if (op === '%') return _wrapL(node.a, 2) + ' \\bmod ' + _wrapL(node.b, 3);
|
||||||
|
// + или - (схлопываем a + (-b) -> a - b и a - (-b) -> a + b)
|
||||||
|
var right = node.b, rop = op;
|
||||||
|
if (op === '+' && _isNeg(right)) { rop = '-'; right = _negate(right); }
|
||||||
|
else if (op === '-' && _isNeg(right)) { rop = '+'; right = _negate(right); }
|
||||||
|
return _wrapL(node.a, 1) + ' ' + rop + ' ' + _wrapL(right, rop === '-' ? 2 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function exprToLatex(src) {
|
||||||
|
var ast;
|
||||||
|
try { ast = SE().parse(String(src)); } catch (e) { return null; }
|
||||||
|
try { return _latex(ast); } catch (e2) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Подстановочная верификация корня ──
|
||||||
|
Истинно, если левая и правая части совпадают при answerVar = value. */
|
||||||
|
function verifyRoot(problem, value) {
|
||||||
|
var env = {};
|
||||||
|
env[problem.answerVar || 'x'] = value;
|
||||||
|
var L = evalExpr(problem.lhsExpr, env);
|
||||||
|
var R = evalExpr(problem.rhsExpr, env);
|
||||||
|
var residual = Math.abs(L - R);
|
||||||
|
var scale = Math.max(1, Math.abs(L), Math.abs(R));
|
||||||
|
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Эквивалентность выражений численным сэмплингом ──
|
||||||
|
Истинно, если exprA и exprB совпадают в нескольких точках по переменным vars
|
||||||
|
(для проверки упрощения/раскрытия: 3x+5x ≡ 8x, a(x+b) ≡ ax+ab). Точки
|
||||||
|
фиксированы → детерминированно (без Math.random). */
|
||||||
|
var _EQUIV_PTS = [-3.7, -1.3, 0.5, 2.1, 4.9, -0.9, 3.3, 1.7];
|
||||||
|
function _sampleEquiv(exprA, exprB, vars) {
|
||||||
|
var ca = SE().compile(String(exprA)), cb = SE().compile(String(exprB));
|
||||||
|
if (ca.error || cb.error) return { ok: false, reason: 'parse' };
|
||||||
|
vars = (vars && vars.length) ? vars : ['x'];
|
||||||
|
for (var i = 0; i < _EQUIV_PTS.length; i++) {
|
||||||
|
var env = {};
|
||||||
|
for (var v = 0; v < vars.length; v++) env[vars[v]] = _EQUIV_PTS[(i + v * 3) % _EQUIV_PTS.length];
|
||||||
|
var a = ca.fn(env), b = cb.fn(env);
|
||||||
|
var scale = Math.max(1, Math.abs(a), Math.abs(b));
|
||||||
|
if (Math.abs(a - b) > 1e-6 * scale) return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Материализация одного экземпляра ──
|
||||||
|
Возвращает problem или null, если за maxTries не удалось выполнить
|
||||||
|
ограничения / целочисленность / самопроверку. */
|
||||||
|
function instantiate(gen, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
|
||||||
|
var maxTries = opts.maxTries || 300;
|
||||||
|
var answerVar = gen.answerVar || 'x';
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < maxTries; attempt++) {
|
||||||
|
var env = {};
|
||||||
|
var pk = gen.pick || {}, k;
|
||||||
|
var lvl = opts.level;
|
||||||
|
for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) {
|
||||||
|
var rk = (lvl && !gen.noScale) ? _scaleRange(pk[k], lvl) : pk[k];
|
||||||
|
env[k] = randInt(rng, rk[0], rk[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gen.constraint && !truthy(evalExpr(gen.constraint, env))) continue;
|
||||||
|
|
||||||
|
if (gen.derive) {
|
||||||
|
for (k in gen.derive) if (Object.prototype.hasOwnProperty.call(gen.derive, k)) {
|
||||||
|
env[k] = evalExpr(gen.derive[k], env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gen.require && !truthy(evalExpr(gen.require, env))) continue;
|
||||||
|
|
||||||
|
var kind = gen.kind || 'solve';
|
||||||
|
|
||||||
|
// корни: одиночный (answer) или множественный (answers — массив выражений)
|
||||||
|
var answers = null;
|
||||||
|
if (Array.isArray(gen.answers)) {
|
||||||
|
answers = gen.answers.map(function (a) { return evalExpr(a, env); });
|
||||||
|
if (gen.integerAnswer) answers = answers.map(function (x) { return Math.round(x); });
|
||||||
|
}
|
||||||
|
var answer = gen.answer ? evalExpr(gen.answer, env) : (answers ? answers[0] : 0);
|
||||||
|
if (gen.answer && gen.integerAnswer) {
|
||||||
|
if (!isIntApprox(answer)) continue;
|
||||||
|
answer = Math.round(answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// система уравнений (kind system): набор строк + пара-ответ {x,y,...}
|
||||||
|
var system = null, pair = null;
|
||||||
|
if (kind === 'system') {
|
||||||
|
system = (gen.eqs || []).map(function (e) { return { lhs: render(e.lhs, env), rhs: render(e.rhs, env) }; });
|
||||||
|
pair = {};
|
||||||
|
var avs = gen.answerVars || ['x', 'y'];
|
||||||
|
for (var ai = 0; ai < avs.length; ai++) {
|
||||||
|
var pv = evalExpr((gen.answers && gen.answers[avs[ai]]) || '0', env);
|
||||||
|
pair[avs[ai]] = gen.integerAnswer ? Math.round(pv) : pv;
|
||||||
|
}
|
||||||
|
answer = pair[avs[0]]; // запасной одиночный ответ
|
||||||
|
}
|
||||||
|
|
||||||
|
var lhsExpr = render(gen.lhs || 'x', env);
|
||||||
|
var rhsExpr = render(gen.rhs || 'x', env);
|
||||||
|
var sEnv = assign(env, { ans: answer });
|
||||||
|
// factorize: добавляет в шаги решения СТРОКУ разложения на простые множители
|
||||||
|
// (повторяющиеся простые, без степеней: «36» -> «2*2*3*3»). gen.factorize —
|
||||||
|
// массив { name, of }: name — ключ для {name} в шагах, of — выражение-число.
|
||||||
|
if (gen.factorize) {
|
||||||
|
for (var fzi = 0; fzi < gen.factorize.length; fzi++) {
|
||||||
|
var fz = gen.factorize[fzi];
|
||||||
|
sEnv[fz.name] = primeFactorString(evalExpr(fz.of, env));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var answerExpr = gen.answerExpr ? render(gen.answerExpr, env) : null;
|
||||||
|
var answerRel = (kind === 'inequality') ? { op: gen.relOp || '<', bound: evalExpr(gen.bound, env) } : null;
|
||||||
|
// latex: уравнение (solve/roots) | выражение (simplify) | неравенство (inequality)
|
||||||
|
// | null (compute → текстовый prompt из display).
|
||||||
|
var latex = null;
|
||||||
|
if (kind === 'solve' || kind === 'roots') {
|
||||||
|
var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr);
|
||||||
|
if (ll != null && rl != null) latex = ll + ' = ' + rl;
|
||||||
|
} else if (kind === 'simplify' && gen.srcExpr) {
|
||||||
|
latex = exprToLatex(render(gen.srcExpr, env));
|
||||||
|
} else if (kind === 'inequality') {
|
||||||
|
latex = exprToLatex(lhsExpr + ' ' + (gen.dispOp || '<') + ' ' + rhsExpr);
|
||||||
|
} else if (kind === 'system' && system) {
|
||||||
|
var rows = [], okrows = true;
|
||||||
|
for (var si2 = 0; si2 < system.length; si2++) {
|
||||||
|
var l2 = exprToLatex(system[si2].lhs), r2 = exprToLatex(system[si2].rhs);
|
||||||
|
if (l2 == null || r2 == null) { okrows = false; break; }
|
||||||
|
rows.push(l2 + ' = ' + r2);
|
||||||
|
}
|
||||||
|
if (okrows) latex = '\\begin{cases} ' + rows.join(' \\\\ ') + ' \\end{cases}';
|
||||||
|
}
|
||||||
|
|
||||||
|
var problem = {
|
||||||
|
genId: gen.id,
|
||||||
|
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
|
||||||
|
title: gen.title,
|
||||||
|
kind: kind,
|
||||||
|
figure: gen.figure || null, // спека чертежа (данные) — рисует TrainerFigures по params
|
||||||
|
lhsExpr: lhsExpr,
|
||||||
|
rhsExpr: rhsExpr,
|
||||||
|
display: (kind === 'system' && system)
|
||||||
|
? system.map(function (e) { return prettyMath(e.lhs + ' = ' + e.rhs); }).join('; ')
|
||||||
|
: prettyMath(render(gen.display || (gen.lhs + (kind === 'inequality' ? (' ' + (gen.dispOp || '<') + ' ') : ' = ') + gen.rhs), env)),
|
||||||
|
latex: latex,
|
||||||
|
answerVar: answerVar,
|
||||||
|
answer: answer,
|
||||||
|
answers: answers, // массив корней (kind roots)
|
||||||
|
answerExpr: answerExpr, // канон. выражение (kind simplify)
|
||||||
|
answerRel: answerRel, // { op, bound } (kind inequality)
|
||||||
|
system: system, // [{lhs,rhs},…] (kind system)
|
||||||
|
pair: pair, // эталонная пара {x,y,…} (kind system)
|
||||||
|
answerVars: gen.answerVars || [answerVar],
|
||||||
|
params: env,
|
||||||
|
// шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) }
|
||||||
|
// строковый шаг (легаси) трактуется как чистая заметка без формулы.
|
||||||
|
solution: (gen.solution || []).map(function (st) {
|
||||||
|
if (typeof st === 'string') return { note: render(st, sEnv), tex: '', latex: null };
|
||||||
|
var texSrc = st.tex ? render(st.tex, sEnv) : '';
|
||||||
|
return {
|
||||||
|
note: st.note ? render(st.note, sEnv) : '',
|
||||||
|
tex: texSrc ? prettyMath(texSrc) : '',
|
||||||
|
latex: texSrc ? exprToLatex(texSrc) : null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Самопроверка по типу: simplify → эквивалентность; roots → все корни; иначе → корень.
|
||||||
|
var okSelf, why;
|
||||||
|
if (kind === 'simplify') {
|
||||||
|
okSelf = _sampleEquiv(render(gen.srcExpr || gen.lhs || 'x', env), answerExpr, problem.answerVars).ok;
|
||||||
|
why = 'упрощение не эквивалентно ответу';
|
||||||
|
} else if (kind === 'inequality') {
|
||||||
|
var bnd = answerRel.bound, iop = answerRel.op;
|
||||||
|
var inside = (iop === '<' || iop === '<=') ? bnd - 1 : bnd + 1;
|
||||||
|
var outside = (iop === '<' || iop === '<=') ? bnd + 1 : bnd - 1;
|
||||||
|
okSelf = _origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, inside) &&
|
||||||
|
!_origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, outside);
|
||||||
|
why = 'неравенство не согласовано с ответом';
|
||||||
|
} else if (kind === 'system') {
|
||||||
|
okSelf = !!(system && system.length) && system.every(function (e) {
|
||||||
|
var L = evalExpr(e.lhs, pair), R = evalExpr(e.rhs, pair);
|
||||||
|
return Math.abs(L - R) <= EPS * Math.max(1, Math.abs(L), Math.abs(R));
|
||||||
|
});
|
||||||
|
why = 'пара не удовлетворяет системе';
|
||||||
|
} else if (answers) {
|
||||||
|
okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; });
|
||||||
|
why = 'не все корни удовлетворяют уравнению';
|
||||||
|
} else {
|
||||||
|
var v = verifyRoot(problem, answer);
|
||||||
|
okSelf = v.ok; why = 'корень ' + fmtNum(answer) + ' не удовлетворяет (невязка ' + v.residual + ')';
|
||||||
|
}
|
||||||
|
if (!okSelf) {
|
||||||
|
if (opts.strict) throw new Error('Генератор «' + gen.id + '»: ' + why + '.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Пакет из n различных по виду задач ── */
|
||||||
|
function generateBatch(gen, n, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
|
||||||
|
var out = [], seen = Object.create(null);
|
||||||
|
var guard = n * 20 + 50;
|
||||||
|
while (out.length < n && guard-- > 0) {
|
||||||
|
var p = instantiate(gen, { rng: rng, strict: opts.strict, maxTries: opts.maxTries, level: opts.level });
|
||||||
|
if (!p) break;
|
||||||
|
if (seen[p.display]) continue;
|
||||||
|
seen[p.display] = 1;
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Проверка ответа ученика ──
|
||||||
|
Принимает строку/число. SimExpr.compile сам срезает ведущее «x=», поэтому
|
||||||
|
"x = 5", "5", "10/2", "2+3" нормализуются к числу. Верно, если значение
|
||||||
|
удовлетворяет уравнению (эквивалентные формы проходят) ИЛИ совпадает с
|
||||||
|
эталонным корнем (страховка единственности для будущих многокорневых типов). */
|
||||||
|
function checkStudentAnswer(problem, input) {
|
||||||
|
var raw = String(input == null ? '' : input).trim();
|
||||||
|
if (!raw) return { ok: false, reason: 'empty', value: null, residual: null, message: 'Введите ответ.' };
|
||||||
|
|
||||||
|
if (problem.kind === 'simplify') return _checkEquiv(problem, raw);
|
||||||
|
if (problem.kind === 'roots') return _checkMultiRoot(problem, raw);
|
||||||
|
if (problem.kind === 'inequality') return _checkInequality(problem, raw);
|
||||||
|
if (problem.kind === 'system') return _checkSystem(problem, raw);
|
||||||
|
|
||||||
|
var c = SE().compile(raw);
|
||||||
|
if (c.error) {
|
||||||
|
return { ok: false, reason: 'parse', value: null, residual: null,
|
||||||
|
message: 'Не понял ответ: ' + c.error };
|
||||||
|
}
|
||||||
|
var val = c.fn({});
|
||||||
|
if (!isFinite(val)) {
|
||||||
|
return { ok: false, reason: 'nan', value: val, residual: null, message: 'Это не число.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
var v = verifyRoot(problem, val);
|
||||||
|
var nearCanonical = Math.abs(val - problem.answer) <= 1e-6 * Math.max(1, Math.abs(problem.answer));
|
||||||
|
var ok = v.ok || nearCanonical;
|
||||||
|
return {
|
||||||
|
ok: ok, reason: ok ? null : 'wrong', value: val, residual: v.residual,
|
||||||
|
message: ok ? 'Верно!' : 'Пока неверно.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Разбор типовой ошибки ученика (репетитор, направление C) ──
|
||||||
|
По неверному ЧИСЛОВОМУ ответу пытается распознать типовую ошибку и дать
|
||||||
|
адресную подсказку, НЕ выдавая правильный ответ. Работает для solve/compute.
|
||||||
|
Для solve уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам
|
||||||
|
(без структуры генератора) → ловим «забыл разделить на коэффициент». Плюс
|
||||||
|
общие эвристики: перепутан знак, близкая арифметическая ошибка.
|
||||||
|
Возвращает { type, hint } или null (ошибка не распознана / ответ верный). */
|
||||||
|
function _linAB(problem) {
|
||||||
|
var av = problem.answerVar || 'x';
|
||||||
|
var e0 = {}, e1 = {}; e0[av] = 0; e1[av] = 1;
|
||||||
|
var g0 = evalExpr(problem.lhsExpr, e0) - evalExpr(problem.rhsExpr, e0);
|
||||||
|
var g1 = evalExpr(problem.lhsExpr, e1) - evalExpr(problem.rhsExpr, e1);
|
||||||
|
if (!isFinite(g0) || !isFinite(g1)) return null;
|
||||||
|
return { A: g1 - g0, B: g0 }; // f(x) = A·x + B, корень = -B/A
|
||||||
|
}
|
||||||
|
function analyzeMistake(problem, value) {
|
||||||
|
if (!problem || !isFinite(value)) return null;
|
||||||
|
var kind = problem.kind || 'solve';
|
||||||
|
if (kind !== 'solve' && kind !== 'compute') return null; // пара/корни/неравенство — отдельно
|
||||||
|
var correct = problem.answer;
|
||||||
|
var tol = 1e-6 * Math.max(1, Math.abs(correct));
|
||||||
|
if (Math.abs(value - correct) <= tol) return null; // на самом деле верно
|
||||||
|
|
||||||
|
// структурно: линейное уравнение → «забыл разделить на коэффициент»
|
||||||
|
if (kind === 'solve') {
|
||||||
|
var ab = _linAB(problem);
|
||||||
|
if (ab && Math.abs(ab.A) > 1.5) {
|
||||||
|
var noDivide = -ab.B; // значение на шаге «A·x = -B», ещё не делённое на A (= A·correct)
|
||||||
|
if (Math.abs(value - noDivide) <= Math.max(tol, 1e-6 * Math.abs(noDivide)))
|
||||||
|
return { type: 'nodivide', hint: 'Похоже, ты не разделил обе части на коэффициент при переменной (' + fmtNum(ab.A) + '). Раздели — и получишь ответ.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// перепутан знак ответа
|
||||||
|
if (correct !== 0 && Math.abs(value + correct) <= Math.max(tol, 1e-6 * Math.abs(correct)))
|
||||||
|
return { type: 'sign', hint: 'Кажется, перепутан знак. Проверь знаки при переносе слагаемых через знак «=».' };
|
||||||
|
// близкая арифметическая ошибка
|
||||||
|
if (Math.abs(value - correct) <= Math.max(1, Math.abs(correct) * 0.2))
|
||||||
|
return { type: 'arith', hint: 'Очень близко — похоже на арифметическую ошибку в вычислениях. Пересчитай аккуратно.' };
|
||||||
|
return { type: 'generic', hint: 'Разбери решение по шагам и попробуй похожую задачу.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Система: ученик вводит пару «x = 2; y = 3» (или «2; 3»). Проверяем подстановкой в ОБА уравнения.
|
||||||
|
Метки переменных опциональны; без меток — по порядку answerVars. */
|
||||||
|
function _checkSystem(problem, raw) {
|
||||||
|
var vars = problem.answerVars || ['x', 'y'];
|
||||||
|
var parts = raw.split(/[;,]/).map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
if (parts.length < vars.length) return { ok: false, reason: 'incomplete', message: 'Введите обе переменные, напр. x = 2; y = 3.' };
|
||||||
|
var vals = {}, pos = [];
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var m = parts[i].match(/^([a-zA-Z]\w*)\s*=\s*(.+)$/);
|
||||||
|
var c = SE().compile(m ? m[2] : parts[i]);
|
||||||
|
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял запись «' + parts[i] + '».' };
|
||||||
|
var num = c.fn({});
|
||||||
|
if (!isFinite(num)) return { ok: false, reason: 'nan', message: 'Это не число.' };
|
||||||
|
if (m) vals[m[1]] = num; else pos.push(num);
|
||||||
|
}
|
||||||
|
for (var j = 0; j < vars.length; j++) if (vals[vars[j]] === undefined && pos.length) vals[vars[j]] = pos.shift();
|
||||||
|
for (var j2 = 0; j2 < vars.length; j2++) if (vals[vars[j2]] === undefined) return { ok: false, reason: 'incomplete', message: 'Укажите ' + vars[j2] + '.' };
|
||||||
|
var sys = problem.system || [];
|
||||||
|
for (var e = 0; e < sys.length; e++) {
|
||||||
|
var L = evalExpr(sys[e].lhs, vals), R = evalExpr(sys[e].rhs, vals);
|
||||||
|
if (Math.abs(L - R) > EPS * Math.max(1, Math.abs(L), Math.abs(R)))
|
||||||
|
return { ok: false, reason: 'wrong', value: vals, message: 'Пара не подходит под уравнения системы.' };
|
||||||
|
}
|
||||||
|
return { ok: true, reason: null, value: vals, message: 'Верно!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
|
||||||
|
function _checkMultiRoot(problem, raw) {
|
||||||
|
var parts = raw.split(/[;,\s]+/).filter(Boolean);
|
||||||
|
if (!parts.length) return { ok: false, reason: 'empty', message: 'Введите ответ.' };
|
||||||
|
var vals = [];
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var c = SE().compile(parts[i]);
|
||||||
|
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял ответ.' };
|
||||||
|
var x = c.fn({});
|
||||||
|
if (!isFinite(x)) return { ok: false, reason: 'nan', message: 'Это не число.' };
|
||||||
|
vals.push(x);
|
||||||
|
}
|
||||||
|
var want = (problem.answers || []).slice();
|
||||||
|
if (vals.length !== want.length) return { ok: false, reason: 'count', message: 'Укажите все корни через «;».' };
|
||||||
|
var used = want.map(function () { return false; });
|
||||||
|
for (var j = 0; j < vals.length; j++) {
|
||||||
|
var f = -1;
|
||||||
|
for (var w = 0; w < want.length; w++) {
|
||||||
|
if (!used[w] && Math.abs(vals[j] - want[w]) <= 1e-6 * Math.max(1, Math.abs(want[w]))) { f = w; break; }
|
||||||
|
}
|
||||||
|
if (f < 0) return { ok: false, reason: 'wrong', message: 'Пока неверно.' };
|
||||||
|
used[f] = true;
|
||||||
|
}
|
||||||
|
return { ok: true, reason: null, message: 'Верно!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Упрощение: ответ-выражение проверяем на эквивалентность сэмплингом. */
|
||||||
|
function _checkEquiv(problem, raw) {
|
||||||
|
var c = SE().compile(raw);
|
||||||
|
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял выражение: ' + c.error };
|
||||||
|
var se = _sampleEquiv(raw, problem.answerExpr, problem.answerVars || ['x']);
|
||||||
|
return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Неравенства: проверка ответа-отношения «x < c» ──
|
||||||
|
Парсим отношение ученика, нормализуем к виду «x op c» (переменная слева;
|
||||||
|
если справа — отношение переворачивается), сравниваем op и границу. */
|
||||||
|
function _origIneqHolds(lhsExpr, rhsExpr, op, v, xv) {
|
||||||
|
var env = {}; env[v] = xv;
|
||||||
|
var L = evalExpr(lhsExpr, env), R = evalExpr(rhsExpr, env);
|
||||||
|
switch (op) {
|
||||||
|
case '<': return L < R; case '>': return L > R;
|
||||||
|
case '<=': return L <= R; case '>=': return L >= R;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function _parseRel(raw, v) {
|
||||||
|
var s = String(raw).replace(/≤/g, '<=').replace(/≥/g, '>=').replace(/\s+/g, '');
|
||||||
|
var m = s.match(/<=|>=|<|>/);
|
||||||
|
if (!m) return null;
|
||||||
|
var op = m[0], left = s.slice(0, m.index), right = s.slice(m.index + op.length);
|
||||||
|
if (!left || !right) return null;
|
||||||
|
var cl = SE().compile(left), cr = SE().compile(right);
|
||||||
|
if (cl.error || cr.error) return null;
|
||||||
|
var flip = { '<': '>', '>': '<', '<=': '>=', '>=': '<=' };
|
||||||
|
if (left === v && right !== v && _isConst(cr, v)) { var b = cr.fn({}); return isFinite(b) ? { op: op, bound: b } : null; }
|
||||||
|
if (right === v && left !== v && _isConst(cl, v)) { var b2 = cl.fn({}); return isFinite(b2) ? { op: flip[op], bound: b2 } : null; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function _checkInequality(problem, raw) {
|
||||||
|
var v = problem.answerVar || 'x';
|
||||||
|
var rel = _parseRel(raw, v);
|
||||||
|
if (!rel) return { ok: false, reason: 'parse', message: 'Ответ — неравенство, напр. ' + v + ' < 3.' };
|
||||||
|
var want = problem.answerRel || {};
|
||||||
|
var ok = rel.op === want.op && Math.abs(rel.bound - want.bound) <= 1e-6 * Math.max(1, Math.abs(want.bound));
|
||||||
|
return { ok: ok, reason: ok ? null : 'wrong', value: raw, message: ok ? 'Верно!' : 'Пока неверно.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Пошаговое решение (репетитор): проверка одного шага-равенства ──
|
||||||
|
Шаг = равносильное уравнение (то же множество корней). Идея без решения
|
||||||
|
уравнений: уравнение L=R равносильно исходному ⟺ выполняется во ВСЕХ корнях
|
||||||
|
и НЕ выполняется в точках-не-корнях (то есть сужает x именно до корней).
|
||||||
|
Ловит арифметику (не держится в корне), потерю корня и тождество «0=0». */
|
||||||
|
function _splitEq(s) {
|
||||||
|
var i = String(s).indexOf('=');
|
||||||
|
if (i <= 0 || i >= s.length - 1) return null;
|
||||||
|
if (s.indexOf('=', i + 1) !== -1) return null; // нет цепочек a=b=c и составных ==,<=,>=
|
||||||
|
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
|
||||||
|
}
|
||||||
|
function _isConst(c, v) {
|
||||||
|
var e1 = {}, e2 = {}; e1[v] = 1.3; e2[v] = 2.7;
|
||||||
|
return Math.abs(c.fn(e1) - c.fn(e2)) < 1e-9;
|
||||||
|
}
|
||||||
|
function _isVarOnly(s, v) { return String(s).replace(/\s+/g, '') === v; }
|
||||||
|
function _isSolvedForm(lhs, rhs, v, roots) {
|
||||||
|
var cl = SE().compile(lhs), cr = SE().compile(rhs);
|
||||||
|
if (cl.error || cr.error) return false;
|
||||||
|
var lv = _isVarOnly(lhs, v), rv = _isVarOnly(rhs, v);
|
||||||
|
if (lv && _isConst(cr, v)) { var a = cr.fn({}); return roots.some(function (r) { return Math.abs(a - r) <= 1e-6; }); }
|
||||||
|
if (rv && _isConst(cl, v)) { var b = cl.fn({}); return roots.some(function (r) { return Math.abs(b - r) <= 1e-6; }); }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function checkStep(problem, line) {
|
||||||
|
var raw = String(line == null ? '' : line).trim();
|
||||||
|
if (!raw) return { ok: false, status: 'empty', message: 'Введите шаг — равенство со знаком «=».' };
|
||||||
|
var parts = _splitEq(raw);
|
||||||
|
if (!parts) return { ok: false, status: 'parse', message: 'Шаг — это одно равенство со знаком «=».' };
|
||||||
|
var cl = SE().compile(parts[0]), cr = SE().compile(parts[1]);
|
||||||
|
if (cl.error || cr.error) return { ok: false, status: 'parse', message: 'Не понял выражение в шаге.' };
|
||||||
|
|
||||||
|
var v = problem.answerVar || 'x';
|
||||||
|
var roots = (problem.answers && problem.answers.length) ? problem.answers : [problem.answer];
|
||||||
|
|
||||||
|
// держится во всех корнях?
|
||||||
|
for (var i = 0; i < roots.length; i++) {
|
||||||
|
var env = {}; env[v] = roots[i];
|
||||||
|
var L = cl.fn(env), R = cr.fn(env);
|
||||||
|
if (Math.abs(L - R) > 1e-7 * Math.max(1, Math.abs(L), Math.abs(R)))
|
||||||
|
return { ok: false, status: 'wrong', message: 'Не равносильно: при ' + v + ' = ' + fmtNum(roots[i]) + ' равенство не выполняется.' };
|
||||||
|
}
|
||||||
|
// сужает x до корней? (в не-корнях должно НЕ выполняться)
|
||||||
|
var total = 0, holds = 0;
|
||||||
|
for (var j = 0; j < _EQUIV_PTS.length; j++) {
|
||||||
|
var x = _EQUIV_PTS[j];
|
||||||
|
if (roots.some(function (r) { return Math.abs(x - r) < 1e-6; })) continue;
|
||||||
|
total++; var e2 = {}; e2[v] = x;
|
||||||
|
var L2 = cl.fn(e2), R2 = cr.fn(e2);
|
||||||
|
if (Math.abs(L2 - R2) <= 1e-7 * Math.max(1, Math.abs(L2), Math.abs(R2))) holds++;
|
||||||
|
}
|
||||||
|
if (total > 0 && holds === total)
|
||||||
|
return { ok: false, status: 'identity', message: 'Это тождество — верно при любом ' + v + ' и не приближает к ответу.' };
|
||||||
|
|
||||||
|
var done = _isSolvedForm(parts[0], parts[1], v, roots);
|
||||||
|
return { ok: true, status: done ? 'solved' : 'equivalent', message: done ? 'Готово!' : 'Верный шаг.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
global.TrainerEngine = {
|
||||||
|
instantiate: instantiate,
|
||||||
|
generateBatch: generateBatch,
|
||||||
|
verifyRoot: verifyRoot,
|
||||||
|
checkStudentAnswer: checkStudentAnswer,
|
||||||
|
analyzeMistake: analyzeMistake,
|
||||||
|
checkStep: checkStep,
|
||||||
|
makeRng: makeRng,
|
||||||
|
// мелочи наружу для билдера/тестов
|
||||||
|
render: render,
|
||||||
|
prettyMath: prettyMath,
|
||||||
|
exprToLatex: exprToLatex
|
||||||
|
};
|
||||||
|
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
TrainerAdaptive — адаптивный подбор навыка + очередь повторения (Фаза 2).
|
||||||
|
|
||||||
|
Чистая логика без DOM/сети (тестируется headless). Решает «что дать дальше»,
|
||||||
|
ведя ученика от простого к сложному и возвращая то, в чём он ошибался.
|
||||||
|
|
||||||
|
Приоритет nextSkill():
|
||||||
|
1) In-session повтор: навык, который провалили В ЭТОЙ сессии и подошёл срок
|
||||||
|
(due <= answered). Это лёгкое интервальное повторение внутри сессии.
|
||||||
|
2) Кросс-сессионный повтор: навык с серверным флагом due (срок Leitner прошёл).
|
||||||
|
3) Прогрессия: первый по порядку НЕ освоенный навык (simple → complex).
|
||||||
|
4) Удержание: всё освоено → навык с наименьшей коробкой (box), затем по порядку.
|
||||||
|
На каждом шаге избегаем немедленного повтора последнего навыка, если есть выбор.
|
||||||
|
|
||||||
|
API (window.TrainerAdaptive):
|
||||||
|
nextSkill({ ordered, progress, queue, answered, last }) -> skillId | null
|
||||||
|
onWrong(queue, skill, answered) -> queue' (поставить навык на повтор)
|
||||||
|
onCorrect(queue, skill) -> queue' (снять навык с повтора)
|
||||||
|
sessionStats(events) -> { total, correct, accuracy, skills, weak }
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
|
||||||
|
var GAP_BASE = 2; // через сколько задач навык всплывёт после ошибки
|
||||||
|
var GAP_MAX = 8;
|
||||||
|
|
||||||
|
function nextSkill(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var ordered = opts.ordered || [];
|
||||||
|
var prog = opts.progress || {};
|
||||||
|
var queue = opts.queue || [];
|
||||||
|
var answered = opts.answered || 0;
|
||||||
|
var last = opts.last || null;
|
||||||
|
if (!ordered.length) return null;
|
||||||
|
|
||||||
|
var ids = ordered.map(function (g) { return g.id; });
|
||||||
|
function known(id) { return ids.indexOf(id) !== -1; }
|
||||||
|
function pos(id) { return ids.indexOf(id); }
|
||||||
|
function notLast(id) { return id !== last; }
|
||||||
|
|
||||||
|
// 1) In-session повтор: подошедшие по сроку записи очереди.
|
||||||
|
var dueQ = queue.filter(function (q) { return q.due <= answered && known(q.skill); })
|
||||||
|
.sort(function (a, b) { return a.due - b.due; });
|
||||||
|
var pick1 = dueQ.filter(function (q) { return notLast(q.skill); })[0] || (dueQ.length === 1 ? dueQ[0] : null);
|
||||||
|
if (pick1) return pick1.skill;
|
||||||
|
|
||||||
|
// 2) Кросс-сессионный повтор: серверный due (срок Leitner прошёл).
|
||||||
|
var overdue = ordered.filter(function (g) { var p = prog[g.id]; return p && p.due && notLast(g.id); });
|
||||||
|
if (overdue.length) return overdue[0].id;
|
||||||
|
|
||||||
|
// 3) Прогрессия: первый по порядку не освоенный.
|
||||||
|
var prog1 = ordered.filter(function (g) { var p = prog[g.id]; return !(p && p.mastered) && notLast(g.id); });
|
||||||
|
if (prog1.length) return prog1[0].id;
|
||||||
|
|
||||||
|
// 4) Удержание: всё освоено — наименьшая коробка, затем по порядку.
|
||||||
|
var pool = ordered.filter(function (g) { return notLast(g.id); });
|
||||||
|
if (!pool.length) pool = ordered.slice();
|
||||||
|
pool.sort(function (a, b) {
|
||||||
|
var ba = (prog[a.id] && prog[a.id].box) || 0;
|
||||||
|
var bb = (prog[b.id] && prog[b.id].box) || 0;
|
||||||
|
return ba - bb || (pos(a.id) - pos(b.id));
|
||||||
|
});
|
||||||
|
return pool.length ? pool[0].id : ids[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWrong(queue, skill, answered) {
|
||||||
|
queue = queue || [];
|
||||||
|
var existing = queue.filter(function (q) { return q.skill === skill; })[0];
|
||||||
|
var gap = existing ? Math.min((existing.gap || GAP_BASE) + 2, GAP_MAX) : GAP_BASE;
|
||||||
|
var rest = queue.filter(function (q) { return q.skill !== skill; });
|
||||||
|
rest.push({ skill: skill, due: (answered || 0) + gap, gap: gap });
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCorrect(queue, skill) {
|
||||||
|
return (queue || []).filter(function (q) { return q.skill !== skill; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStats(events) {
|
||||||
|
events = events || [];
|
||||||
|
var total = events.length;
|
||||||
|
var correct = 0, bySkill = {};
|
||||||
|
events.forEach(function (e) {
|
||||||
|
if (e.correct) correct++;
|
||||||
|
var s = bySkill[e.skill] || (bySkill[e.skill] = { c: 0, n: 0 });
|
||||||
|
s.n++; if (e.correct) s.c++;
|
||||||
|
});
|
||||||
|
var skills = Object.keys(bySkill);
|
||||||
|
var weak = skills.filter(function (s) { return bySkill[s].c < bySkill[s].n; });
|
||||||
|
return {
|
||||||
|
total: total,
|
||||||
|
correct: correct,
|
||||||
|
accuracy: total ? Math.round(100 * correct / total) : 0,
|
||||||
|
skills: skills,
|
||||||
|
weak: weak
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
global.TrainerAdaptive = {
|
||||||
|
nextSkill: nextSkill,
|
||||||
|
onWrong: onWrong,
|
||||||
|
onCorrect: onCorrect,
|
||||||
|
sessionStats: sessionStats
|
||||||
|
};
|
||||||
|
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
TrainerFigures — чертежи геометрических задач тренажёра. ДАННЫЕ, не код.
|
||||||
|
|
||||||
|
Идея (та же модель безопасности, что у SimForge «объекты — это данные» и
|
||||||
|
math6-svg): генератор задачи НЕ содержит SVG/кода — он лишь ссылается на
|
||||||
|
ИМЕНОВАННЫЙ тип фигуры и привязки её размеров к параметрам задачи:
|
||||||
|
|
||||||
|
figure: { type:'right-triangle', a:'a', b:'b', c:'c', unknown:'c' }
|
||||||
|
|
||||||
|
Здесь 'a'/'b'/'c' — имена параметров уже материализованной задачи
|
||||||
|
(problem.params, числа). Рендерер сам строит SVG ИЗ ЧИСЕЛ → ⛔ без eval/
|
||||||
|
new Function, без пользовательских строк в разметке (текст-подписи
|
||||||
|
экранируются). Чертёж — ИЛЛЮСТРАЦИЯ рядом с условием: показывает данные
|
||||||
|
величины и «?» на искомой; математику по-прежнему считает движок (SimExpr).
|
||||||
|
|
||||||
|
Цвета подобраны под индиго-сцену героя (белые штрихи на тёмном фоне; при
|
||||||
|
верном ответе сцена зеленеет, при неверном — краснеет, белое читается на всех).
|
||||||
|
|
||||||
|
API (window.TrainerFigures):
|
||||||
|
render(figureSpec, params) -> svgString | null (null, если тип неизвестен)
|
||||||
|
has(type) -> bool
|
||||||
|
TYPES -> { type: fn }
|
||||||
|
|
||||||
|
Контракт типа: fn(spec, params, U) -> body-строка (внутренности <svg>).
|
||||||
|
U — утилиты (num/lbl/fit/ln/pgon/txt/arc/rightAngle/…).
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
|
||||||
|
// ── размер холста и поля под подписи ──
|
||||||
|
var VB_W = 268, VB_H = 184, MARGIN = 38;
|
||||||
|
|
||||||
|
// ── палитра (только в SVG-стоки: stroke/fill — мусор не исполняется) ──
|
||||||
|
var STROKE = 'rgba(255,255,255,.94)'; // основные линии
|
||||||
|
var FILLSH = 'rgba(255,255,255,.10)'; // лёгкая заливка фигуры
|
||||||
|
var DASH = 'rgba(255,255,255,.78)'; // вспомогательные (высоты, диагонали)
|
||||||
|
var ARC = '#fde68a'; // дуги углов (тёплый, виден на всех фонах)
|
||||||
|
var UNK = '#fde68a'; // подпись искомой величины «?»
|
||||||
|
var VERTEX = 'rgba(255,255,255,.96)'; // точки-вершины
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аккуратное число для подписи (целое без хвоста, иначе до 2 знаков).
|
||||||
|
function fmt(v) {
|
||||||
|
if (typeof v !== 'number' || !isFinite(v)) return '';
|
||||||
|
if (Math.abs(v - Math.round(v)) < 1e-9) return String(Math.round(v));
|
||||||
|
return String(Math.round(v * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрешение привязки: число → как есть; строка-имя параметра → params[name];
|
||||||
|
// числовая строка → parseFloat; иначе undefined.
|
||||||
|
function num(params, ref) {
|
||||||
|
if (typeof ref === 'number') return isFinite(ref) ? ref : undefined;
|
||||||
|
if (typeof ref === 'string') {
|
||||||
|
if (params && Object.prototype.hasOwnProperty.call(params, ref)) {
|
||||||
|
var v = params[ref];
|
||||||
|
return (typeof v === 'number' && isFinite(v)) ? v : undefined;
|
||||||
|
}
|
||||||
|
var f = parseFloat(ref);
|
||||||
|
if (isFinite(f)) return f;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подпись величины: «?» (искомая) или число. unknownKey сравнивается с key.
|
||||||
|
function lbl(val, key, unknownKey) {
|
||||||
|
if (unknownKey != null && key === unknownKey) return '?';
|
||||||
|
return fmt(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── геометрия в МАТЕМАТИЧЕСКИХ координатах (y вверх), затем fit→экран ──
|
||||||
|
function P(x, y) { return { x: x, y: y }; }
|
||||||
|
function add(a, b) { return P(a.x + b.x, a.y + b.y); }
|
||||||
|
function sub(a, b) { return P(a.x - b.x, a.y - b.y); }
|
||||||
|
function mul(a, k) { return P(a.x * k, a.y * k); }
|
||||||
|
function len(a) { return Math.hypot(a.x, a.y); }
|
||||||
|
function norm(a) { var l = len(a) || 1; return P(a.x / l, a.y / l); }
|
||||||
|
function mid(a, b) { return P((a.x + b.x) / 2, (a.y + b.y) / 2); }
|
||||||
|
function deg2rad(d) { return d * Math.PI / 180; }
|
||||||
|
|
||||||
|
// Подгонка набора мат-точек в холст: возвращает f.px(p) → экранная точка (y вниз).
|
||||||
|
function fit(pts) {
|
||||||
|
var minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
|
||||||
|
for (var i = 0; i < pts.length; i++) {
|
||||||
|
var p = pts[i];
|
||||||
|
if (p.x < minx) minx = p.x; if (p.x > maxx) maxx = p.x;
|
||||||
|
if (p.y < miny) miny = p.y; if (p.y > maxy) maxy = p.y;
|
||||||
|
}
|
||||||
|
var w = Math.max(1e-6, maxx - minx), h = Math.max(1e-6, maxy - miny);
|
||||||
|
var availW = VB_W - 2 * MARGIN, availH = VB_H - 2 * MARGIN;
|
||||||
|
var s = Math.min(availW / w, availH / h);
|
||||||
|
var drawW = w * s, drawH = h * s;
|
||||||
|
var ox = (VB_W - drawW) / 2, oy = (VB_H - drawH) / 2;
|
||||||
|
return {
|
||||||
|
s: s,
|
||||||
|
px: function (p) { return P(ox + (p.x - minx) * s, oy + (maxy - p.y) * s); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── примитивы рисования (принимают ЭКРАННЫЕ точки) ──
|
||||||
|
function ln(a, b, opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
return '<line x1="' + r1(a.x) + '" y1="' + r1(a.y) + '" x2="' + r1(b.x) + '" y2="' + r1(b.y) +
|
||||||
|
'" stroke="' + (opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) +
|
||||||
|
'" stroke-linecap="round"' + (opt.dash ? ' stroke-dasharray="5 5"' : '') + '/>';
|
||||||
|
}
|
||||||
|
function pgon(ptsScreen, opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
var d = ptsScreen.map(function (p) { return r1(p.x) + ',' + r1(p.y); }).join(' ');
|
||||||
|
return '<polygon points="' + d + '" fill="' + (opt.fill || FILLSH) + '" stroke="' +
|
||||||
|
(opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) + '" stroke-linejoin="round"/>';
|
||||||
|
}
|
||||||
|
function dot(p, rr) {
|
||||||
|
return '<circle cx="' + r1(p.x) + '" cy="' + r1(p.y) + '" r="' + (rr || 2.6) + '" fill="' + VERTEX + '"/>';
|
||||||
|
}
|
||||||
|
// Текст с тёмным гало (paint-order) для читаемости на любом фоне сцены.
|
||||||
|
function txt(p, s, opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
var fill = opt.fill || '#fff';
|
||||||
|
var size = opt.size || 13.5;
|
||||||
|
var anchor = opt.anchor || 'middle';
|
||||||
|
var weight = opt.weight || 700;
|
||||||
|
return '<text x="' + r1(p.x) + '" y="' + r1(p.y) + '" text-anchor="' + anchor +
|
||||||
|
'" dominant-baseline="middle" font-family="Manrope, system-ui, sans-serif" font-size="' + size +
|
||||||
|
'" font-weight="' + weight + '" fill="' + fill +
|
||||||
|
'" style="paint-order:stroke;stroke:rgba(15,23,42,.45);stroke-width:3.2px;stroke-linejoin:round">' +
|
||||||
|
esc(s) + '</text>';
|
||||||
|
}
|
||||||
|
// Подпись величины у середины ребра a-b, отодвинутая НАРУЖУ от точки away.
|
||||||
|
function edgeLabel(a, b, away, text, opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
var m = mid(a, b);
|
||||||
|
var n = norm(sub(m, away)); // от центра наружу
|
||||||
|
var off = opt.off || 16;
|
||||||
|
var pos = P(m.x + n.x * off, m.y + n.y * off);
|
||||||
|
return txt(pos, text, opt);
|
||||||
|
}
|
||||||
|
function r1(n) { return Math.round(n * 10) / 10; }
|
||||||
|
|
||||||
|
// Дуга угла в вершине V между лучами на A и B; рисует короткую (внутреннюю) дугу
|
||||||
|
// радиуса rad экранных px. Возвращает { path, labelPos } (labelPos — для подписи).
|
||||||
|
function angleArc(Vs, As, Bs, rad) {
|
||||||
|
var a0 = Math.atan2(As.y - Vs.y, As.x - Vs.x);
|
||||||
|
var a1 = Math.atan2(Bs.y - Vs.y, Bs.x - Vs.x);
|
||||||
|
var d = a1 - a0;
|
||||||
|
while (d > Math.PI) d -= 2 * Math.PI;
|
||||||
|
while (d < -Math.PI) d += 2 * Math.PI;
|
||||||
|
var n = 14, pts = [];
|
||||||
|
for (var i = 0; i <= n; i++) {
|
||||||
|
var a = a0 + d * (i / n);
|
||||||
|
pts.push(P(Vs.x + Math.cos(a) * rad, Vs.y + Math.sin(a) * rad));
|
||||||
|
}
|
||||||
|
var path = '<path d="M ' + pts.map(function (p) { return r1(p.x) + ' ' + r1(p.y); }).join(' L ') +
|
||||||
|
'" fill="none" stroke="' + ARC + '" stroke-width="2.4" stroke-linecap="round"/>';
|
||||||
|
var amid = a0 + d / 2;
|
||||||
|
var labelPos = P(Vs.x + Math.cos(amid) * (rad + 14), Vs.y + Math.sin(amid) * (rad + 14));
|
||||||
|
return { path: path, labelPos: labelPos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маркер прямого угла в вершине Vs, стороны к As и Bs (экранные), размер m px.
|
||||||
|
function rightAngle(Vs, As, Bs, m) {
|
||||||
|
m = m || 13;
|
||||||
|
var u = norm(sub(As, Vs)), w = norm(sub(Bs, Vs));
|
||||||
|
var p1 = add(Vs, mul(u, m)), p3 = add(Vs, mul(w, m)), p2 = add(p1, mul(w, m));
|
||||||
|
return '<path d="M ' + r1(p1.x) + ' ' + r1(p1.y) + ' L ' + r1(p2.x) + ' ' + r1(p2.y) +
|
||||||
|
' L ' + r1(p3.x) + ' ' + r1(p3.y) + '" fill="none" stroke="' + STROKE +
|
||||||
|
'" stroke-width="2"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var U = {
|
||||||
|
num: num, lbl: lbl, fmt: fmt, P: P, add: add, sub: sub, mul: mul, len: len,
|
||||||
|
norm: norm, mid: mid, deg2rad: deg2rad, fit: fit, ln: ln, pgon: pgon, dot: dot,
|
||||||
|
txt: txt, edgeLabel: edgeLabel, angleArc: angleArc, rightAngle: rightAngle,
|
||||||
|
STROKE: STROKE, FILLSH: FILLSH, DASH: DASH, ARC: ARC, UNK: UNK
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ════════════════ ТИПЫ ФИГУР ════════════════ */
|
||||||
|
var TYPES = {
|
||||||
|
|
||||||
|
/* Прямоугольный треугольник (Пифагор).
|
||||||
|
a — вертикальный катет, b — горизонтальный катет, c — гипотенуза.
|
||||||
|
unknown ∈ {a,b,c} — какая величина искомая (рисуется «?»). */
|
||||||
|
'right-triangle': function (spec, p) {
|
||||||
|
var a = num(p, spec.a), b = num(p, spec.b), c = num(p, spec.c);
|
||||||
|
if (!(a > 0) || !(b > 0)) return null;
|
||||||
|
// мат-координаты: прямой угол в A (0,0), горизонт. катет B(b,0), верт. катет C(0,a)
|
||||||
|
var A = P(0, 0), B = P(b, 0), C = P(0, a);
|
||||||
|
var f = fit([A, B, C]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Cs = f.px(C);
|
||||||
|
var body = pgon([As, Bs, Cs]);
|
||||||
|
body += rightAngle(As, Bs, Cs, 13);
|
||||||
|
body += dot(As) + dot(Bs) + dot(Cs);
|
||||||
|
// подписи: горизонт. катет (A-B) ← b; верт. катет (A-C) ← a; гипотенуза (B-C) ← c
|
||||||
|
body += edgeLabel(As, Bs, Cs, lbl(b, 'b', spec.unknown), unkOpt('b', spec.unknown));
|
||||||
|
body += edgeLabel(As, Cs, Bs, lbl(a, 'a', spec.unknown), unkOpt('a', spec.unknown));
|
||||||
|
body += edgeLabel(Bs, Cs, As, (c != null ? lbl(c, 'c', spec.unknown) : '?'), unkOpt('c', spec.unknown));
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Углы треугольника. angA — левый, angB — правый базовые углы.
|
||||||
|
Без ext: вершина (апекс) — искомый угол «?».
|
||||||
|
ext:true — внешний угол при правой вершине = angA+angB (рисуется «?»),
|
||||||
|
апекс получает angB, правый внутренний = 180−angA−angB. */
|
||||||
|
'triangle-angles': function (spec, p) {
|
||||||
|
var ax = num(p, spec.angA), bx = num(p, spec.angB);
|
||||||
|
if (!(ax > 0) || !(bx > 0)) return null;
|
||||||
|
var ext = !!spec.ext;
|
||||||
|
var alpha = ax; // левый внутренний угол
|
||||||
|
var gamma = ext ? (180 - ax - bx) : bx; // правый внутренний угол
|
||||||
|
if (!(gamma > 0) || alpha + gamma >= 179.5) return null;
|
||||||
|
// апекс T из L,R по внутренним углам alpha (слева), gamma (справа)
|
||||||
|
var L = P(0, 0), R = P(1, 0);
|
||||||
|
var s = Math.sin(deg2rad(alpha)) / Math.sin(deg2rad(alpha + gamma)); // s = |R→…| масштаб
|
||||||
|
var T = add(R, mul(P(-Math.cos(deg2rad(gamma)), Math.sin(deg2rad(gamma))), s));
|
||||||
|
var basePts = [L, R, T];
|
||||||
|
var E = null;
|
||||||
|
if (ext) { E = P(R.x + (R.x - L.x) * 0.55, 0); basePts.push(E); } // продолжение базы за R
|
||||||
|
var f = fit(basePts);
|
||||||
|
var Ls = f.px(L), Rs = f.px(R), Ts = f.px(T);
|
||||||
|
var body = pgon([Ls, Rs, Ts]);
|
||||||
|
if (ext) {
|
||||||
|
var Es = f.px(E);
|
||||||
|
body += ln(Rs, Es, { dash: false, w: 2.2 }); // продолжение стороны (базы)
|
||||||
|
}
|
||||||
|
body += dot(Ls) + dot(Rs) + dot(Ts);
|
||||||
|
// дуги+подписи углов
|
||||||
|
var arcL = angleArc(Ls, Rs, Ts, 22);
|
||||||
|
body += arcL.path + txt(arcL.labelPos, fmt(ax) + '°', { fill: '#fff', size: 12.5 });
|
||||||
|
if (ext) {
|
||||||
|
var arcT = angleArc(Ts, Ls, Rs, 22);
|
||||||
|
body += arcT.path + txt(arcT.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
|
||||||
|
// внешний угол при R: между продолжением базы (Es) и стороной R→T
|
||||||
|
var arcE = angleArc(Rs, f.px(E), Ts, 22);
|
||||||
|
body += arcE.path + txt(arcE.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
|
||||||
|
} else {
|
||||||
|
var arcR = angleArc(Rs, Ts, Ls, 22);
|
||||||
|
body += arcR.path + txt(arcR.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
|
||||||
|
var arcTa = angleArc(Ts, Ls, Rs, 24);
|
||||||
|
body += arcTa.path + txt(arcTa.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Смежные углы: прямая через O, луч вверх под углом ang к правой части.
|
||||||
|
Известный угол ang (справа от луча) и искомый «?» (слева, = 180−ang). */
|
||||||
|
'adjacent-angles': function (spec, p) {
|
||||||
|
var ang = num(p, spec.ang);
|
||||||
|
if (!(ang > 0) || ang >= 180) return null;
|
||||||
|
var O = P(0, 0), Lp = P(-1, 0), Rp = P(1, 0);
|
||||||
|
var ray = P(Math.cos(deg2rad(ang)), Math.sin(deg2rad(ang))); // луч вверх
|
||||||
|
var f = fit([Lp, Rp, O, ray]);
|
||||||
|
var Os = f.px(O), Ls = f.px(Lp), Rs = f.px(Rp), Rays = f.px(ray);
|
||||||
|
var body = ln(Ls, Rs, { w: 2.6 }); // прямая
|
||||||
|
body += ln(Os, Rays, { w: 2.6 }); // луч
|
||||||
|
body += dot(Os);
|
||||||
|
var arcR = angleArc(Os, Rs, Rays, 26); // известный угол (справа)
|
||||||
|
body += arcR.path + txt(arcR.labelPos, fmt(ang) + '°', { fill: '#fff', size: 12.5 });
|
||||||
|
var arcL = angleArc(Os, Rays, Ls, 26); // искомый (слева)
|
||||||
|
body += arcL.path + txt(arcL.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Прямоугольник: w (горизонталь), h (вертикаль). Обе стороны подписаны. */
|
||||||
|
'rectangle': function (spec, p) {
|
||||||
|
var w = num(p, spec.w), h = num(p, spec.h);
|
||||||
|
if (!(w > 0) || !(h > 0)) return null;
|
||||||
|
var A = P(0, 0), B = P(w, 0), C = P(w, h), D = P(0, h);
|
||||||
|
var f = fit([A, B, C, D]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(w / 2, h / 2));
|
||||||
|
var body = pgon([As, Bs, Cs, Ds]);
|
||||||
|
body += edgeLabel(As, Bs, cen, fmt(w), {});
|
||||||
|
body += edgeLabel(Bs, Cs, cen, fmt(h), {});
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Квадрат со стороной a (подписаны две смежные стороны). */
|
||||||
|
'square': function (spec, p) {
|
||||||
|
var a = num(p, spec.a);
|
||||||
|
if (!(a > 0)) return null;
|
||||||
|
var A = P(0, 0), B = P(a, 0), C = P(a, a), D = P(0, a);
|
||||||
|
var f = fit([A, B, C, D]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(a / 2, a / 2));
|
||||||
|
var body = pgon([As, Bs, Cs, Ds]);
|
||||||
|
body += edgeLabel(As, Bs, cen, fmt(a), {});
|
||||||
|
body += edgeLabel(As, Ds, cen, fmt(a), {});
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Треугольник по основанию и высоте: base (горизонт.), height (пунктирная высота). */
|
||||||
|
'triangle-base-height': function (spec, p) {
|
||||||
|
var base = num(p, spec.base), h = num(p, spec.height);
|
||||||
|
if (!(base > 0) || !(h > 0)) return null;
|
||||||
|
// апекс смещён (не равнобедренный, чтобы высота была наглядна), но проекция внутри основания
|
||||||
|
var apexX = base * 0.62;
|
||||||
|
var A = P(0, 0), B = P(base, 0), T = P(apexX, h), F = P(apexX, 0); // F — основание высоты
|
||||||
|
var f = fit([A, B, T]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Ts = f.px(T), Fs = f.px(F), cen = f.px(P(base / 2, h / 3));
|
||||||
|
var body = pgon([As, Bs, Ts]);
|
||||||
|
body += ln(Ts, Fs, { dash: true, stroke: DASH, w: 2 }); // высота
|
||||||
|
body += rightAngle(Fs, Bs, Ts, 11); // прямой угол у основания высоты
|
||||||
|
body += dot(As) + dot(Bs) + dot(Ts);
|
||||||
|
body += edgeLabel(As, Bs, Ts, fmt(base), {}); // основание
|
||||||
|
body += txt(P((Ts.x + Fs.x) / 2 + 14, (Ts.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'start' });
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Трапеция: основания spec.bottom и spec.top (порядок неважен — оба основания),
|
||||||
|
height (пунктирная высота). Длинное основание рисуем СНИЗУ → ножка высоты
|
||||||
|
всегда внутри, подписи = реальные длины. */
|
||||||
|
'trapezoid': function (spec, p) {
|
||||||
|
var x1 = num(p, spec.bottom), x2 = num(p, spec.top), h = num(p, spec.height);
|
||||||
|
if (!(x1 > 0) || !(x2 > 0) || !(h > 0)) return null;
|
||||||
|
var bot = Math.max(x1, x2), top = Math.min(x1, x2);
|
||||||
|
var offset = (bot - top) / 2; // верхнее основание по центру
|
||||||
|
var A = P(0, 0), B = P(bot, 0), C = P(offset + top, h), D = P(offset, h);
|
||||||
|
var f = fit([A, B, C, D]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(bot / 2, h / 2));
|
||||||
|
var body = pgon([As, Bs, Cs, Ds]);
|
||||||
|
var Fs = f.px(P(offset, 0)); // ножка высоты на нижнем основании
|
||||||
|
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
|
||||||
|
body += rightAngle(Fs, Bs, Ds, 10);
|
||||||
|
body += edgeLabel(As, Bs, cen, fmt(bot), {}); // нижнее основание
|
||||||
|
body += edgeLabel(Ds, Cs, cen, fmt(top), {}); // верхнее основание
|
||||||
|
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Параллелограмм: base (нижняя сторона), height (пунктирная высота к ней). */
|
||||||
|
'parallelogram': function (spec, p) {
|
||||||
|
var base = num(p, spec.base), h = num(p, spec.height);
|
||||||
|
if (!(base > 0) || !(h > 0)) return null;
|
||||||
|
var skew = base * 0.32;
|
||||||
|
var A = P(0, 0), B = P(base, 0), C = P(base + skew, h), D = P(skew, h);
|
||||||
|
var f = fit([A, B, C, D]);
|
||||||
|
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(base / 2 + skew / 2, h / 2));
|
||||||
|
var body = pgon([As, Bs, Cs, Ds]);
|
||||||
|
// высота от D перпендикулярно основанию (на проекцию D=skew)
|
||||||
|
var Fs = f.px(P(skew, 0));
|
||||||
|
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
|
||||||
|
body += rightAngle(Fs, Bs, Ds, 10);
|
||||||
|
body += edgeLabel(As, Bs, cen, fmt(base), {});
|
||||||
|
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Ромб по диагоналям d1 (горизонт.), d2 (верт.) — диагонали пунктиром. */
|
||||||
|
'rhombus': function (spec, p) {
|
||||||
|
var d1 = num(p, spec.d1), d2 = num(p, spec.d2);
|
||||||
|
if (!(d1 > 0) || !(d2 > 0)) return null;
|
||||||
|
var R = P(d1 / 2, 0), L = P(-d1 / 2, 0), Tp = P(0, d2 / 2), Bt = P(0, -d2 / 2);
|
||||||
|
var f = fit([R, L, Tp, Bt]);
|
||||||
|
var Rs = f.px(R), Ls = f.px(L), Ts = f.px(Tp), Bs = f.px(Bt), Os = f.px(P(0, 0));
|
||||||
|
var body = pgon([Rs, Ts, Ls, Bs]);
|
||||||
|
body += ln(Ls, Rs, { dash: true, stroke: DASH, w: 1.8 }); // горизонтальная диагональ
|
||||||
|
body += ln(Bs, Ts, { dash: true, stroke: DASH, w: 1.8 }); // вертикальная диагональ
|
||||||
|
body += dot(Os, 2.2);
|
||||||
|
body += txt(P((Os.x + Rs.x) / 2, Os.y - 11), fmt(d1), { fill: '#fff', size: 12.5 });
|
||||||
|
body += txt(P(Os.x + 13, (Os.y + Ts.y) / 2), fmt(d2), { fill: '#fff', size: 12.5, anchor: 'start' });
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Правильный n-угольник; markAngle:true — отметить один внутренний угол «?». */
|
||||||
|
'regular-polygon': function (spec, p) {
|
||||||
|
var n = Math.round(num(p, spec.n));
|
||||||
|
if (!(n >= 3) || n > 24) return null;
|
||||||
|
var pts = [];
|
||||||
|
var start = Math.PI / 2 + (n % 2 === 0 ? Math.PI / n : 0); // плоской стороной вниз
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var a = start + i * 2 * Math.PI / n;
|
||||||
|
pts.push(P(Math.cos(a), Math.sin(a)));
|
||||||
|
}
|
||||||
|
var f = fit(pts);
|
||||||
|
var sp = pts.map(function (pt) { return f.px(pt); });
|
||||||
|
var body = pgon(sp);
|
||||||
|
for (var j = 0; j < sp.length; j++) body += dot(sp[j], 2.3);
|
||||||
|
if (spec.markAngle) {
|
||||||
|
var v = sp[0], prev = sp[(n - 1) % n], next = sp[1];
|
||||||
|
var arc = angleArc(v, prev, next, 16);
|
||||||
|
body += arc.path + txt(arc.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Две подобные фигуры (треугольники): слева — оригинал, справа — увеличенный в k раз.
|
||||||
|
mode:'side' — подписаны сходственные стороны (a и «?»);
|
||||||
|
mode:'perimeter' — подписаны периметры (P и «?»). k подписан между ними. */
|
||||||
|
'two-similar': function (spec, p) {
|
||||||
|
var k = num(p, spec.k);
|
||||||
|
if (!(k > 0)) return null;
|
||||||
|
var mode = spec.mode || 'side';
|
||||||
|
var known = (mode === 'perimeter') ? num(p, spec.perim) : num(p, spec.side);
|
||||||
|
var vk = Math.min(1.85, Math.max(1.15, k)); // визуальный масштаб (не буквальный k)
|
||||||
|
// базовый треугольник (форма), две копии бок о бок
|
||||||
|
var shape = [P(0, 0), P(1.0, 0), P(0.35, 0.85)];
|
||||||
|
function place(scale, dx) {
|
||||||
|
return shape.map(function (pt) { return P(pt.x * scale + dx, pt.y * scale); });
|
||||||
|
}
|
||||||
|
var t1 = place(1, 0);
|
||||||
|
var gap = 0.6;
|
||||||
|
var t2 = place(vk, 1.0 + gap);
|
||||||
|
var f = fit(t1.concat(t2));
|
||||||
|
var s1 = t1.map(function (pt) { return f.px(pt); });
|
||||||
|
var s2 = t2.map(function (pt) { return f.px(pt); });
|
||||||
|
var body = pgon(s1) + pgon(s2);
|
||||||
|
var c1 = f.px(P((t1[0].x + t1[1].x + t1[2].x) / 3, (t1[0].y + t1[1].y + t1[2].y) / 3));
|
||||||
|
var c2 = f.px(P((t2[0].x + t2[1].x + t2[2].x) / 3, (t2[0].y + t2[1].y + t2[2].y) / 3));
|
||||||
|
if (mode === 'perimeter') {
|
||||||
|
body += txt(P(c1.x, c1.y), (known != null ? 'P=' + fmt(known) : 'P'), { fill: '#fff', size: 12.5 });
|
||||||
|
body += txt(P(c2.x, c2.y), '?', { fill: UNK, size: 16, weight: 800 });
|
||||||
|
} else {
|
||||||
|
// подпись нижней (сходственной) стороны каждого треугольника
|
||||||
|
body += edgeLabel(s1[0], s1[1], c1, (known != null ? fmt(known) : ''), {});
|
||||||
|
body += edgeLabel(s2[0], s2[1], c2, '?', { fill: UNK, size: 15, weight: 800 });
|
||||||
|
}
|
||||||
|
// коэффициент подобия между фигурами
|
||||||
|
var between = P((c1.x + c2.x) / 2, Math.min(c1.y, c2.y) - 6);
|
||||||
|
body += txt(between, 'k = ' + fmt(k), { fill: ARC, size: 12.5, weight: 800 });
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Опции подписи: искомая величина — амбер/крупнее.
|
||||||
|
function unkOpt(key, unknownKey) {
|
||||||
|
if (unknownKey != null && key === unknownKey) return { fill: UNK, size: 16, weight: 800 };
|
||||||
|
return { fill: '#fff', size: 13 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(figureSpec, params) {
|
||||||
|
if (!figureSpec || typeof figureSpec !== 'object') return null;
|
||||||
|
var fn = TYPES[figureSpec.type];
|
||||||
|
if (typeof fn !== 'function') return null;
|
||||||
|
var body;
|
||||||
|
try { body = fn(figureSpec, params || {}, U); }
|
||||||
|
catch (e) { return null; }
|
||||||
|
if (!body) return null;
|
||||||
|
return '<svg class="tr-fig-svg" viewBox="0 0 ' + VB_W + ' ' + VB_H +
|
||||||
|
'" role="img" aria-hidden="true" preserveAspectRatio="xMidYMid meet" ' +
|
||||||
|
'style="overflow:visible">' + body + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
global.TrainerFigures = {
|
||||||
|
render: render,
|
||||||
|
has: function (type) { return typeof TYPES[type] === 'function'; },
|
||||||
|
TYPES: TYPES,
|
||||||
|
_util: U
|
||||||
|
};
|
||||||
|
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
|
|||||||
LS.notif.init();
|
LS.notif.init();
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
const feats = await LS.loadFeatures();
|
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?.();
|
LS.hideDisabledFeatures?.();
|
||||||
|
|
||||||
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
|
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
|
||||||
|
|||||||
+138
-27
@@ -10,7 +10,12 @@
|
|||||||
<div class="fn-row">
|
<div class="fn-row">
|
||||||
<div class="fn-dot"></div>
|
<div class="fn-dot"></div>
|
||||||
<span class="fn-label">y =</span>
|
<span class="fn-label">y =</span>
|
||||||
<input class="fn-input" id="fn0" placeholder="sin(x)" autocomplete="off" spellcheck="false" oninput="updateFn(0)" />
|
<div class="fn-field">
|
||||||
|
<input class="fn-input" id="fn0" placeholder="sin(x)" autocomplete="off" spellcheck="false" oninput="updateFn(0)" />
|
||||||
|
<div class="fn-math" id="fn0-math" title="Нажми, чтобы изменить"></div>
|
||||||
|
</div>
|
||||||
|
<button class="fn-act" id="fn0-eye" type="button" title="Скрыть/показать" onclick="toggleFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="fn-preview" id="fn0-prev"></div>
|
<div class="fn-preview" id="fn0-prev"></div>
|
||||||
<div class="fn-err" id="fn0-err">Синтаксическая ошибка</div>
|
<div class="fn-err" id="fn0-err">Синтаксическая ошибка</div>
|
||||||
@@ -21,7 +26,12 @@
|
|||||||
<div class="fn-row">
|
<div class="fn-row">
|
||||||
<div class="fn-dot"></div>
|
<div class="fn-dot"></div>
|
||||||
<span class="fn-label">y =</span>
|
<span class="fn-label">y =</span>
|
||||||
<input class="fn-input" id="fn1" placeholder="x^2 - 4" autocomplete="off" spellcheck="false" oninput="updateFn(1)" />
|
<div class="fn-field">
|
||||||
|
<input class="fn-input" id="fn1" placeholder="x^2 - 4" autocomplete="off" spellcheck="false" oninput="updateFn(1)" />
|
||||||
|
<div class="fn-math" id="fn1-math" title="Нажми, чтобы изменить"></div>
|
||||||
|
</div>
|
||||||
|
<button class="fn-act" id="fn1-eye" type="button" title="Скрыть/показать" onclick="toggleFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="fn-preview" id="fn1-prev"></div>
|
<div class="fn-preview" id="fn1-prev"></div>
|
||||||
<div class="fn-err" id="fn1-err">Синтаксическая ошибка</div>
|
<div class="fn-err" id="fn1-err">Синтаксическая ошибка</div>
|
||||||
@@ -32,56 +42,77 @@
|
|||||||
<div class="fn-row">
|
<div class="fn-row">
|
||||||
<div class="fn-dot"></div>
|
<div class="fn-dot"></div>
|
||||||
<span class="fn-label">y =</span>
|
<span class="fn-label">y =</span>
|
||||||
<input class="fn-input" id="fn2" placeholder="tg(x)" autocomplete="off" spellcheck="false" oninput="updateFn(2)" />
|
<div class="fn-field">
|
||||||
|
<input class="fn-input" id="fn2" placeholder="tg(x)" autocomplete="off" spellcheck="false" oninput="updateFn(2)" />
|
||||||
|
<div class="fn-math" id="fn2-math" title="Нажми, чтобы изменить"></div>
|
||||||
|
</div>
|
||||||
|
<button class="fn-act" id="fn2-eye" type="button" title="Скрыть/показать" onclick="toggleFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="fn-preview" id="fn2-prev"></div>
|
<div class="fn-preview" id="fn2-prev"></div>
|
||||||
<div class="fn-err" id="fn2-err">Синтаксическая ошибка</div>
|
<div class="fn-err" id="fn2-err">Синтаксическая ошибка</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:8px"></div>
|
<div style="margin-top:8px"></div>
|
||||||
|
<div class="gp-section-title">Вставить</div>
|
||||||
|
<div class="gp-keypad">
|
||||||
|
<button class="kp-btn" data-ins="^2" data-tex="x^2" onclick="graphInsert(this.dataset.ins)">x²</button>
|
||||||
|
<button class="kp-btn" data-ins="^" data-tex="x^n" onclick="graphInsert(this.dataset.ins)">xⁿ</button>
|
||||||
|
<button class="kp-btn" data-ins="sqrt(|)" data-tex="\sqrt{x}" onclick="graphInsert(this.dataset.ins)">√</button>
|
||||||
|
<button class="kp-btn" data-ins="/" data-tex="\tfrac{a}{b}" onclick="graphInsert(this.dataset.ins)">a/b</button>
|
||||||
|
<button class="kp-btn" data-ins="abs(|)" data-tex="|x|" onclick="graphInsert(this.dataset.ins)">|x|</button>
|
||||||
|
<button class="kp-btn" data-ins="pi" data-tex="\pi" onclick="graphInsert(this.dataset.ins)">π</button>
|
||||||
|
<button class="kp-btn" data-ins="sin(|)" data-tex="\sin" onclick="graphInsert(this.dataset.ins)">sin</button>
|
||||||
|
<button class="kp-btn" data-ins="cos(|)" data-tex="\cos" onclick="graphInsert(this.dataset.ins)">cos</button>
|
||||||
|
<button class="kp-btn" data-ins="tg(|)" data-tex="\operatorname{tg}" onclick="graphInsert(this.dataset.ins)">tg</button>
|
||||||
|
<button class="kp-btn" data-ins="ln(|)" data-tex="\ln" onclick="graphInsert(this.dataset.ins)">ln</button>
|
||||||
|
<button class="kp-btn" data-ins="exp(|)" data-tex="e^x" onclick="graphInsert(this.dataset.ins)">eˣ</button>
|
||||||
|
<button class="kp-btn" data-ins="(|)" data-tex="(\;)" onclick="graphInsert(this.dataset.ins)">( )</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gp-section-title">Примеры</div>
|
<div class="gp-section-title">Примеры</div>
|
||||||
|
|
||||||
<div class="gp-preset-group">
|
<div class="gp-preset-group">
|
||||||
<div class="gp-preset-label">Линейные / степенные</div>
|
<div class="gp-preset-label">Линейные / степенные</div>
|
||||||
<div class="presets-wrap">
|
<div class="presets-wrap">
|
||||||
<button class="preset-btn" onclick="applyPreset('2x-1')">2x−1</button>
|
<button class="preset-btn" data-tex="2x-1" onclick="applyPreset('2x-1')">2x−1</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('x^2')">x²</button>
|
<button class="preset-btn" data-tex="x^2" onclick="applyPreset('x^2')">x²</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('x^2-4')">x²−4</button>
|
<button class="preset-btn" data-tex="x^2-4" onclick="applyPreset('x^2-4')">x²−4</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('x^3-3x')">x³−3x</button>
|
<button class="preset-btn" data-tex="x^3-3x" onclick="applyPreset('x^3-3x')">x³−3x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('x^4-4x^2+3')">x⁴−4x²+3</button>
|
<button class="preset-btn" data-tex="x^4-4x^2+3" onclick="applyPreset('x^4-4x^2+3')">x⁴−4x²+3</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gp-preset-group">
|
<div class="gp-preset-group">
|
||||||
<div class="gp-preset-label">Тригонометрия</div>
|
<div class="gp-preset-label">Тригонометрия</div>
|
||||||
<div class="presets-wrap">
|
<div class="presets-wrap">
|
||||||
<button class="preset-btn" onclick="applyPreset('sin(x)')">sin x</button>
|
<button class="preset-btn" data-tex="\sin x" onclick="applyPreset('sin(x)')">sin x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('cos(x)')">cos x</button>
|
<button class="preset-btn" data-tex="\cos x" onclick="applyPreset('cos(x)')">cos x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('tg(x)')">tg x</button>
|
<button class="preset-btn" data-tex="\operatorname{tg} x" onclick="applyPreset('tg(x)')">tg x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('sin(2x)')">sin 2x</button>
|
<button class="preset-btn" data-tex="\sin 2x" onclick="applyPreset('sin(2x)')">sin 2x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('x*sin(x)')">x·sin x</button>
|
<button class="preset-btn" data-tex="x\,\sin x" onclick="applyPreset('x*sin(x)')">x·sin x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('sin(x)/x')">sin(x)/x</button>
|
<button class="preset-btn" data-tex="\tfrac{\sin x}{x}" onclick="applyPreset('sin(x)/x')">sin(x)/x</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gp-preset-group">
|
<div class="gp-preset-group">
|
||||||
<div class="gp-preset-label">Показательные / логарифмы</div>
|
<div class="gp-preset-label">Показательные / логарифмы</div>
|
||||||
<div class="presets-wrap">
|
<div class="presets-wrap">
|
||||||
<button class="preset-btn" onclick="applyPreset('exp(x)')">eˣ</button>
|
<button class="preset-btn" data-tex="e^x" onclick="applyPreset('exp(x)')">eˣ</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('2^x')">2ˣ</button>
|
<button class="preset-btn" data-tex="2^x" onclick="applyPreset('2^x')">2ˣ</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('ln(x)')">ln x</button>
|
<button class="preset-btn" data-tex="\ln x" onclick="applyPreset('ln(x)')">ln x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('log(x)')">log x</button>
|
<button class="preset-btn" data-tex="\log x" onclick="applyPreset('log(x)')">log x</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gp-preset-group">
|
<div class="gp-preset-group">
|
||||||
<div class="gp-preset-label">Прочие</div>
|
<div class="gp-preset-label">Прочие</div>
|
||||||
<div class="presets-wrap">
|
<div class="presets-wrap">
|
||||||
<button class="preset-btn" onclick="applyPreset('sqrt(x)')">√x</button>
|
<button class="preset-btn" data-tex="\sqrt{x}" onclick="applyPreset('sqrt(x)')">√x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('1/x')">1/x</button>
|
<button class="preset-btn" data-tex="\tfrac{1}{x}" onclick="applyPreset('1/x')">1/x</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('abs(x)')">|x|</button>
|
<button class="preset-btn" data-tex="|x|" onclick="applyPreset('abs(x)')">|x|</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('floor(x)')">⌊x⌋</button>
|
<button class="preset-btn" data-tex="\lfloor x \rfloor" onclick="applyPreset('floor(x)')">⌊x⌋</button>
|
||||||
<button class="preset-btn" onclick="applyPreset('1/(1+exp(-x))')">σ(x)</button>
|
<button class="preset-btn" data-tex="\sigma(x)" onclick="applyPreset('1/(1+exp(-x))')">σ(x)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,6 +127,12 @@
|
|||||||
<div class="graph-canvas-outer">
|
<div class="graph-canvas-outer">
|
||||||
<div class="graph-canvas-wrap">
|
<div class="graph-canvas-wrap">
|
||||||
<canvas id="graph-canvas"></canvas>
|
<canvas id="graph-canvas"></canvas>
|
||||||
|
<div class="graph-view-ctrls">
|
||||||
|
<button class="gv-btn" id="graph-pts-btn" type="button" title="Особые точки: нули, пересечения, y-перехват" onclick="toggleGraphPoints()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="2"/><circle cx="13" cy="15" r="2"/><circle cx="19" cy="6" r="2"/></svg></button>
|
||||||
|
<button class="gv-btn" type="button" title="Приблизить" onclick="graphZoom(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||||
|
<button class="gv-btn" type="button" title="Отдалить" onclick="graphZoom(-1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||||
|
<button class="gv-btn" type="button" title="Сбросить вид" onclick="graphFit()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 9 4 4 9 4"/><polyline points="20 9 20 4 15 4"/><polyline points="4 15 4 20 9 20"/><polyline points="20 15 20 20 15 20"/></svg></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="graph-info-bar" id="graph-info-bar">
|
<div class="graph-info-bar" id="graph-info-bar">
|
||||||
<div class="info-coord">
|
<div class="info-coord">
|
||||||
@@ -506,6 +543,16 @@
|
|||||||
<!-- left panel -->
|
<!-- left panel -->
|
||||||
<div class="proj-panel" style="width:240px;gap:0">
|
<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 -->
|
<!-- Function toggles -->
|
||||||
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
|
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
||||||
@@ -535,9 +582,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Graph function selector -->
|
<!-- Graph (functions) — optional, can be hidden to focus on the circle -->
|
||||||
<div class="gp-section-title" style="margin-bottom:8px">График</div>
|
<label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
|
<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 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('cos',this)" style="--fc:#06D6E0">cos</button>
|
||||||
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
|
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
|
||||||
@@ -553,6 +605,55 @@
|
|||||||
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot">—</span>
|
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot">—</span>
|
||||||
</div>
|
</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 -->
|
<!-- Notable angles -->
|
||||||
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
|
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
|
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
|
||||||
@@ -562,8 +663,16 @@
|
|||||||
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
|
<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(Math.PI/2)">90°</button>
|
||||||
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</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(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(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>
|
</div>
|
||||||
|
|
||||||
<!-- Angle info -->
|
<!-- Angle info -->
|
||||||
@@ -583,8 +692,10 @@
|
|||||||
</div><!-- /.proj-panel -->
|
</div><!-- /.proj-panel -->
|
||||||
|
|
||||||
<!-- canvas -->
|
<!-- canvas -->
|
||||||
<div class="proj-canvas-outer">
|
<div class="proj-canvas-outer" style="position:relative">
|
||||||
<canvas id="trigcircle-canvas"></canvas>
|
<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>
|
||||||
|
|
||||||
</div><!-- /.sim-body-wrap -->
|
</div><!-- /.sim-body-wrap -->
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
|
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
|
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
|
||||||
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
|
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
|
||||||
|
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||||
|
.mm-tag { font-size: 0.7rem; font-weight: 600; color: var(--violet); background: rgba(155,93,229,0.10); border: 1px solid rgba(155,93,229,0.22); border-radius: 99px; padding: 2px 9px; cursor: pointer; }
|
||||||
|
.mm-tag:hover { background: rgba(155,93,229,0.18); }
|
||||||
|
.mm-tagbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.mm-tagbar:empty { display: none; }
|
||||||
|
.mm-tagf { font-size: 0.76rem; font-weight: 600; color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: 99px; padding: 4px 11px; cursor: pointer; }
|
||||||
|
.mm-tagf:hover { border-color: rgba(155,93,229,0.4); }
|
||||||
|
.mm-tagf.active { background: var(--violet); color: #fff; border-color: var(--violet); }
|
||||||
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
|
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
|
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
|
||||||
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
|
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
@@ -110,6 +118,7 @@
|
|||||||
<option value="link">Ссылки</option>
|
<option value="link">Ссылки</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mm-tagbar" id="mm-tags"></div>
|
||||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +181,30 @@
|
|||||||
}
|
}
|
||||||
let _mats = [];
|
let _mats = [];
|
||||||
let _cols = [];
|
let _cols = [];
|
||||||
const _filter = { col: 'all', kind: 'all', q: '' };
|
const _filter = { col: 'all', kind: 'all', q: '', tag: null };
|
||||||
|
|
||||||
|
/* ── Теги (хранятся строкой через запятую в m.tags) ── */
|
||||||
|
function tagsOf(m) { return String((m && m.tags) || '').split(',').map(t => t.trim()).filter(Boolean); }
|
||||||
|
function normTags(str) { const seen = new Set(), out = []; String(str || '').split(',').forEach(t => { t = t.trim().slice(0, 40); const k = t.toLowerCase(); if (t && !seen.has(k)) { seen.add(k); out.push(t); } }); return out.slice(0, 12).join(', '); }
|
||||||
|
function tagsHtml(m) {
|
||||||
|
const ts = tagsOf(m);
|
||||||
|
if (!ts.length) return '';
|
||||||
|
return `<div class="mm-tags">${ts.map(t => `<span class="mm-tag" onclick="event.stopPropagation();setTag('${esc(t).replace(/'/g, "'")}')">#${esc(t)}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
function allTags() {
|
||||||
|
const set = new Map();
|
||||||
|
_mats.forEach(m => tagsOf(m).forEach(t => set.set(t.toLowerCase(), t)));
|
||||||
|
return Array.from(set.values()).sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
|
}
|
||||||
|
function renderTags() {
|
||||||
|
const bar = document.getElementById('mm-tags');
|
||||||
|
if (!bar) return;
|
||||||
|
const tags = allTags();
|
||||||
|
if (!tags.length) { bar.innerHTML = ''; return; }
|
||||||
|
let html = `<span class="mm-tagf${_filter.tag ? '' : ' active'}" onclick="setTag(null)">Все теги</span>`;
|
||||||
|
html += tags.map(t => `<span class="mm-tagf${_filter.tag === t.toLowerCase() ? ' active' : ''}" onclick="setTag('${esc(t).replace(/'/g, "'")}')">#${esc(t)}</span>`).join('');
|
||||||
|
bar.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Move-to-collection select ── */
|
/* ── Move-to-collection select ── */
|
||||||
function moveSelect(m) {
|
function moveSelect(m) {
|
||||||
@@ -202,6 +234,7 @@
|
|||||||
${chip}
|
${chip}
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>
|
||||||
|
${tagsHtml(m)}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
||||||
@@ -225,6 +258,7 @@
|
|||||||
${chip}
|
${chip}
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>
|
||||||
|
${tagsHtml(m)}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
||||||
@@ -304,6 +338,7 @@
|
|||||||
if (_filter.col === 'none' && m.collection_id) return false;
|
if (_filter.col === 'none' && m.collection_id) return false;
|
||||||
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
||||||
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
||||||
|
if (_filter.tag && !tagsOf(m).some(t => t.toLowerCase() === _filter.tag)) return false;
|
||||||
if (_filter.q) {
|
if (_filter.q) {
|
||||||
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||||
if (!hay.includes(_filter.q)) return false;
|
if (!hay.includes(_filter.q)) return false;
|
||||||
@@ -326,6 +361,7 @@
|
|||||||
grid.innerHTML = rows.length
|
grid.innerHTML = rows.length
|
||||||
? rows.map(card).join('')
|
? rows.map(card).join('')
|
||||||
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||||
|
renderTags();
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +381,8 @@
|
|||||||
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
||||||
function onKind(v) { _filter.kind = v; renderGrid(); }
|
function onKind(v) { _filter.kind = v; renderGrid(); }
|
||||||
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
|
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
|
||||||
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
function setTag(t) { const lt = t ? String(t).toLowerCase() : null; _filter.tag = (_filter.tag === lt) ? null : lt; renderGrid(); }
|
||||||
|
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; window.setTag = setTag;
|
||||||
|
|
||||||
/* ── Material actions ── */
|
/* ── Material actions ── */
|
||||||
async function moveMaterial(id, cid) {
|
async function moveMaterial(id, cid) {
|
||||||
@@ -366,6 +403,7 @@
|
|||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
||||||
|
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
@@ -374,7 +412,8 @@
|
|||||||
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
||||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||||
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
||||||
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
|
const tags = normTags(m.body.querySelector('#mm-nt-tags').value);
|
||||||
|
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
@@ -388,12 +427,14 @@
|
|||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
||||||
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
||||||
|
<input id="mm-ed-tags" value="${esc(tagsOf(mt).join(', '))}" placeholder="Теги через запятую (напр. алгебра, формулы)" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
||||||
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
||||||
|
data.tags = normTags(m.body.querySelector('#mm-ed-tags').value);
|
||||||
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
|
|||||||
async function init() {
|
async function init() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
const feats = await LS.loadFeatures().catch(() => ({}));
|
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?.();
|
LS.hideDisabledFeatures?.();
|
||||||
|
|
||||||
// Auth (sidebar)
|
// Auth (sidebar)
|
||||||
|
|||||||
@@ -196,7 +196,8 @@
|
|||||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||||
|
|
||||||
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
||||||
if (LS.loadFeatures) {
|
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
|
||||||
|
if (LS.loadFeatures && !ip.isAdmin) {
|
||||||
LS.loadFeatures().then(function (feats) {
|
LS.loadFeatures().then(function (feats) {
|
||||||
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
|
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
|
||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
|
|||||||
@@ -1418,7 +1418,7 @@
|
|||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Учебники</b> — отключить раздел учебников.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Учебники</b> — отключить раздел учебников.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Флэшкарты, Live-квиз</b> — включить/выключить по необходимости.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Флэшкарты, Live-квиз</b> — включить/выключить по необходимости.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Экзаменационные тесты</b> — модуль с 80 вариантами по математике 9 класса.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Экзаменационные тесты</b> — модуль с 80 вариантами по математике 9 класса.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>И другие</b> — Доска, Классы, Питомец и геймификация, Карта знаний, Красная книга, Кроссворд, Виселица, Коллекция, Квантик-ассистент: каждый модуль скрывается своим флагом.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>И другие</b> — Доска, Классы, Питомец и геймификация, Карта знаний, Красная книга, Кроссворд, Виселица, Коллекция, Live-квиз, Онлайн-урок, Квантик-ассистент, игра «Квантик: Законы Мира», Конструктор симуляций, Путеводитель, трекер пожеланий: каждый модуль скрывается своим флагом. Админ при этом видит и открывает любой модуль, даже выключенный.</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Только для администратора</div>Изменение feature flags и доступа к контенту доступно только пользователям с ролью <b>admin</b>. Учителя видят только то, к чему у их класса есть доступ.</div></div>
|
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Только для администратора</div>Изменение feature flags и доступа к контенту доступно только пользователям с ролью <b>admin</b>. Учителя видят только то, к чему у их класса есть доступ.</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1477,8 +1477,9 @@
|
|||||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Нажмите кнопку-питомца → откроется окно с полем ввода.</div></div>
|
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Нажмите кнопку-питомца → откроется окно с полем ввода.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте вопрос: «объясни теорему Виета», «как создать класс», «реши …». Можно продолжать беседу — Квантик помнит контекст.</div></div>
|
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте вопрос: «объясни теорему Виета», «как создать класс», «реши …». Можно продолжать беседу — Квантик помнит контекст.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В учебнике выделите фрагмент текста — появится кнопка <b>«Объяснить выделенное»</b>.</div></div>
|
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В учебнике выделите фрагмент текста — появится кнопка <b>«Объяснить выделенное»</b>.</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Режим <b>«Тест в банк»</b> (виден учителю/админу): напишите тему — Квантик сгенерирует вопросы с вариантами, вы проверите и сохраните прямо в <b>банк вопросов</b> (с выбором предмета и темы). Тема при этом создаётся автоматически.</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="graduation-cap"></i></div><div class="tg-box-body"><div class="tg-box-label">Для учителей</div>Квантик понимает учительские задачи: попросите составить вопросы по теме, план урока или объяснить, как работает инструмент платформы.</div></div>
|
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="graduation-cap"></i></div><div class="tg-box-body"><div class="tg-box-label">Для учителей</div>Квантик понимает учительские задачи: попросите составить вопросы по теме, план урока или объяснить, как работает инструмент платформы. Он также знает, на какой странице/уроке находится ученик, и отвечает в этом контексте.</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tg-section" id="s-18-3">
|
<div class="tg-section" id="s-18-3">
|
||||||
@@ -1573,19 +1574,22 @@
|
|||||||
<div class="tg-section" id="s-20-2">
|
<div class="tg-section" id="s-20-2">
|
||||||
<div class="tg-section-title">20.2 Игры: Кроссворд и Виселица</div>
|
<div class="tg-section-title">20.2 Игры: Кроссворд и Виселица</div>
|
||||||
<p>Учебные игры для закрепления терминов: <b>Кроссворд</b> (<a href="/crossword">/crossword</a>) и <b>Виселица</b> (<a href="/hangman">/hangman</a>). За прохождение начисляется XP (Глава 16). Включаются/отключаются feature-флагами.</p>
|
<p>Учебные игры для закрепления терминов: <b>Кроссворд</b> (<a href="/crossword">/crossword</a>) и <b>Виселица</b> (<a href="/hangman">/hangman</a>). За прохождение начисляется XP (Глава 16). Включаются/отключаются feature-флагами.</p>
|
||||||
|
<p><b>Квантик: Законы Мира</b> (<a href="/quantik">/quantik</a>) — физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс по карте-созвездию. Уровни можно создавать в Конструкторе симуляций (Глава 21) и раздавать классу.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tg-section" id="s-20-3">
|
<div class="tg-section" id="s-20-3">
|
||||||
<div class="tg-section-title">20.3 Красная книга и Коллекция</div>
|
<div class="tg-section-title">20.3 Красная книга и Коллекция</div>
|
||||||
<p><b>Красная книга</b> (<a href="/red-book">/red-book</a>) — модуль по экологии и биоразнообразию: виды, биомы, экосистемы и игры-квесты. <b>Коллекция</b> (<a href="/collection">/collection</a>) — собранные учеником материалы и награды.</p>
|
<p><b>Красная книга</b> (<a href="/red-book">/red-book</a>) — модуль по экологии и биоразнообразию: виды, биомы, экосистемы и игры-квесты. <b>Коллекция</b> (<a href="/collection">/collection</a>) — карточки по всем темам предметов: ученик «прокачивает» их уровни (бронза → платина), правильно отвечая на вопросы темы. Новые темы появляются в коллекции автоматически, как только в них есть вопросы.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tg-section" id="s-20-4">
|
<div class="tg-section" id="s-20-4">
|
||||||
<div class="tg-section-title">20.4 Мои материалы, Магазин и Родители</div>
|
<div class="tg-section-title">20.4 Мои материалы, Магазин и Родители</div>
|
||||||
<div class="tg-steps">
|
<div class="tg-steps">
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Мои материалы</b> (<a href="/my-materials">/my-materials</a>) — ученик сохраняет к себе доску (PNG) и заметки из онлайн-урока; копия остаётся даже после удаления сессии.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Мои материалы</b> (<a href="/my-materials">/my-materials</a>) — ученик сохраняет к себе доску (PNG), вырезки учебника и заметки; копия остаётся даже после удаления сессии. Можно раскладывать по <b>папкам</b> и помечать <b>тегами</b>, искать и фильтровать по ним. Учитель может раздать материал классу.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Трекер пожеланий</b> (<a href="/wishes">/wishes</a>) — любой пользователь предлагает улучшения платформы; админ их видит и сортирует по статусу. Приватный (автор + админ), а не публичная доска.</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Путеводитель</b> (<a href="/sitemap">/sitemap</a>) — карта-обзор всех разделов платформы со ссылками.</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
|
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1839,7 +1843,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tg-section" id="s-a3-3">
|
<div class="tg-section" id="s-a3-3">
|
||||||
<div class="tg-section-title">A3.3 Feature Flags</div>
|
<div class="tg-section-title">A3.3 Feature Flags</div>
|
||||||
<p>Включение/отключение модулей платформы без перезапуска сервера: биохимия, учебники, флэшкарты, доска, live-квиз, экзамен, симуляции, игры (кроссворд, виселица), красная книга, карта знаний, коллекция, питомец и геймификация, Квантик-ассистент.</p>
|
<p>Включение/отключение модулей платформы без перезапуска сервера: биохимия, учебники, флэшкарты, доска, live-квиз, онлайн-урок, экзамен, симуляции и Конструктор симуляций, игры (кроссворд, виселица), игра «Квантик: Законы Мира», красная книга, карта знаний, коллекция, питомец и геймификация, Квантик-ассистент, Путеводитель, трекер пожеланий. Админ видит и открывает любой модуль независимо от флага.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-chapter-nav">
|
<div class="tg-chapter-nav">
|
||||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a2')">
|
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a2')">
|
||||||
@@ -1990,6 +1994,10 @@
|
|||||||
<div class="tg-section" id="s-a7-2">
|
<div class="tg-section" id="s-a7-2">
|
||||||
<div class="tg-section-title">A7.2 Модели и лимиты</div>
|
<div class="tg-section-title">A7.2 Модели и лимиты</div>
|
||||||
<p>Для провайдера Kilo доступен список бесплатных моделей прямо на карточке (переключатель). Кнопка <b>«Загрузить модели провайдера»</b> в форме подтягивает живой список моделей с их лимитами.</p>
|
<p>Для провайдера Kilo доступен список бесплатных моделей прямо на карточке (переключатель). Кнопка <b>«Загрузить модели провайдера»</b> в форме подтягивает живой список моделей с их лимитами.</p>
|
||||||
|
<div class="tg-steps">
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Сканировать модели</b> — кнопка находит на шлюзе все бесплатные модели, прогоняет каждую тест-запросом на русском и показывает отчёт (новые / исчезнувшие / % русского / скорость); «Применить выбранные» обновляет рабочий список.</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Без ключа</b> — шлюз Pollinations работает без API-ключа (бейдж «без ключа»), годится как бесплатный запасной провайдер.</div></div>
|
||||||
|
</div>
|
||||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="gauge"></i></div><div class="tg-box-body"><div class="tg-box-label">Лимиты моделей</div>Под моделью показывается «контекст · ответ до N токенов · бесплатно/платно» — данные тянутся автоматически из API провайдера (работает и для Gemini, и для новых моделей).</div></div>
|
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="gauge"></i></div><div class="tg-box-body"><div class="tg-box-label">Лимиты моделей</div>Под моделью показывается «контекст · ответ до N токенов · бесплатно/платно» — данные тянутся автоматически из API провайдера (работает и для Gemini, и для новых моделей).</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-section" id="s-a7-3">
|
<div class="tg-section" id="s-a7-3">
|
||||||
@@ -1998,6 +2006,9 @@
|
|||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>RAG по учебникам</b> — тумблер «Искать ответы по учебникам» + кнопка «Переиндексировать учебники».</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>RAG по учебникам</b> — тумблер «Искать ответы по учебникам» + кнопка «Переиндексировать учебники».</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Кнопки на экзамене</b> — тумблер показа «Подсказка / Спросить Квантика» на карточках задач (Глава 18).</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Кнопки на экзамене</b> — тумблер показа «Подсказка / Спросить Квантика» на карточках задач (Глава 18).</div></div>
|
||||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Статистика</b> — запросы к ИИ / из кэша / FAQ за день и за 30 дней, лайки и дизлайки.</div></div>
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Статистика</b> — запросы к ИИ / из кэша / FAQ за день и за 30 дней, лайки и дизлайки.</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Сократический режим</b> — тумблер: для учеников Квантик объясняет теорию полно, но конкретные задачи не решает «под ключ» (даёт метод и наводящий шаг). Авто-включается при просьбе «реши за меня».</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Авто-проверка провайдеров</b> — фоновый пинг каждые 15 мин + кнопка «Проверить сейчас»; упавший активный провайдер сам уступает место здоровому (цветной индикатор на карточке).</div></div>
|
||||||
|
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Знания о системе</b> — кнопка «Проиндексировать систему» снимает срез включённых модулей + ваше «Описание системы», чтобы Квантик знал актуальное состояние платформы и не предлагал отключённое. Снимок сам обновляется при смене фича-флагов.</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Вы изучили полное руководство — все разделы для учителей и администраторов.</div></div>
|
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Вы изучили полное руководство — все разделы для учителей и администраторов.</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -479,6 +479,15 @@
|
|||||||
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
|
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
|
||||||
function openLabSim(simId, ev) {
|
function openLabSim(simId, ev) {
|
||||||
if (ev) ev.stopPropagation();
|
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);
|
location.href = '/lab?sim=' + encodeURIComponent(simId);
|
||||||
}
|
}
|
||||||
window.openLabSim = openLabSim;
|
window.openLabSim = openLabSim;
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
<!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=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/css/ls.css"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
|
||||||
|
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink:#1b1f38; --ink-soft:#5b6378; --ink-faint:#98a1b8;
|
||||||
|
--g1:#6366f1; --g2:#8b5cf6; --accent-ink:#4338ca; --accent-soft:#eef0ff;
|
||||||
|
--ok:#10b981; --ok-ink:#047857; --bad:#ef4444;
|
||||||
|
--sh:0 16px 40px rgba(27,31,56,.09), 0 2px 6px rgba(27,31,56,.04);
|
||||||
|
--ease:cubic-bezier(.22,.61,.36,1);
|
||||||
|
}
|
||||||
|
.sb-content {
|
||||||
|
background-color:#f5f6fb;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1000px 600px at 86% -10%, rgba(139,92,246,.10), transparent 60%),
|
||||||
|
radial-gradient(820px 560px at 2% -6%, rgba(99,102,241,.09), transparent 55%),
|
||||||
|
linear-gradient(rgba(99,102,241,.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(99,102,241,.05) 1px, transparent 1px);
|
||||||
|
background-size:100% 100%,100% 100%,26px 26px,26px 26px; background-attachment:fixed;
|
||||||
|
}
|
||||||
|
.gb-wrap { max-width:1080px; margin:0 auto; padding:30px 20px 90px; }
|
||||||
|
.gb-h1 { font-family:'Manrope',sans-serif; font-weight:800; font-size:clamp(1.5rem,4vw,2rem); letter-spacing:-.02em; color:var(--ink); margin:0 0 4px; }
|
||||||
|
.gb-sub { color:var(--ink-soft); font-size:.95rem; margin-bottom:22px; }
|
||||||
|
.gb-grid { display:grid; grid-template-columns:300px 1fr; gap:20px; align-items:start; }
|
||||||
|
@media (max-width:880px){ .gb-grid { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
.gb-card { background:#fff; border:1px solid rgba(99,102,241,.1); border-radius:18px; box-shadow:var(--sh); padding:20px; }
|
||||||
|
.gb-card h2 { font-family:'Manrope',sans-serif; font-size:1rem; font-weight:800; color:var(--ink); margin:0 0 14px; }
|
||||||
|
|
||||||
|
.gb-list-item { display:flex; align-items:center; gap:8px; padding:10px 12px; border-radius:12px; border:1px solid rgba(99,102,241,.12); margin-bottom:8px; background:#fbfbff; }
|
||||||
|
.gb-li-main { flex:1; min-width:0; cursor:pointer; }
|
||||||
|
.gb-li-title { font-weight:700; color:var(--ink); font-size:.9rem; }
|
||||||
|
.gb-li-meta { font-size:.74rem; color:var(--ink-faint); }
|
||||||
|
.gb-li-pub { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; padding:2px 7px; border-radius:99px; }
|
||||||
|
.gb-li-pub.draft { background:rgba(148,163,184,.16); color:#64748b; }
|
||||||
|
.gb-li-pub.published { background:var(--ok); color:#fff; }
|
||||||
|
.gb-icon-btn { background:none; border:none; cursor:pointer; color:var(--ink-faint); padding:4px; border-radius:8px; }
|
||||||
|
.gb-icon-btn:hover { background:rgba(99,102,241,.1); color:var(--accent-ink); }
|
||||||
|
.gb-icon-btn .ic { width:16px; height:16px; }
|
||||||
|
.gb-empty { color:var(--ink-faint); font-size:.85rem; text-align:center; padding:14px; }
|
||||||
|
|
||||||
|
.gb-field { margin-bottom:14px; }
|
||||||
|
.gb-field label { display:block; font-size:.82rem; font-weight:700; color:var(--ink-soft); margin-bottom:5px; }
|
||||||
|
.gb-field input, .gb-field textarea, .gb-field select {
|
||||||
|
width:100%; font:inherit; padding:9px 12px; border:1px solid rgba(99,102,241,.22); border-radius:10px; outline:none; color:var(--ink); box-sizing:border-box; transition:.15s;
|
||||||
|
}
|
||||||
|
.gb-field input:focus, .gb-field textarea:focus, .gb-field select:focus { border-color:var(--g1); box-shadow:0 0 0 3px rgba(99,102,241,.14); }
|
||||||
|
.gb-field .hint { font-size:.75rem; color:var(--ink-faint); margin-top:4px; font-family:'Cambria Math',serif; }
|
||||||
|
.gb-row2 { display:flex; gap:12px; flex-wrap:wrap; }
|
||||||
|
.gb-row2 > * { flex:1; min-width:140px; }
|
||||||
|
.gb-check { display:flex; align-items:center; gap:8px; font-size:.85rem; font-weight:600; color:var(--ink-soft); }
|
||||||
|
.gb-check input { width:auto; }
|
||||||
|
|
||||||
|
.gb-rows { display:flex; flex-direction:column; gap:7px; }
|
||||||
|
.gb-rrow { display:flex; gap:7px; align-items:center; }
|
||||||
|
.gb-rrow input { font:inherit; padding:7px 10px; border:1px solid rgba(99,102,241,.2); border-radius:9px; outline:none; min-width:0; }
|
||||||
|
.gb-rrow .nm { width:64px; flex:0 0 auto; font-family:'Cambria Math',serif; }
|
||||||
|
.gb-rrow .num { width:60px; flex:0 0 auto; }
|
||||||
|
.gb-rrow .grow { flex:1; font-family:'Cambria Math',serif; }
|
||||||
|
.gb-add { font:inherit; font-size:.82rem; font-weight:700; cursor:pointer; color:var(--accent-ink); background:rgba(99,102,241,.08); border:1px dashed rgba(99,102,241,.3); border-radius:9px; padding:6px 12px; margin-top:7px; }
|
||||||
|
.gb-add:hover { background:var(--accent-soft); }
|
||||||
|
|
||||||
|
.gb-btn { font:inherit; font-weight:700; cursor:pointer; border:none; border-radius:12px; padding:11px 20px; transition:.16s var(--ease); display:inline-flex; align-items:center; gap:7px; }
|
||||||
|
.gb-btn .ic { width:16px; height:16px; }
|
||||||
|
.gb-primary { color:#fff; background:linear-gradient(135deg,var(--g1),var(--g2)); box-shadow:0 8px 20px rgba(99,102,241,.3); }
|
||||||
|
.gb-primary:hover { transform:translateY(-2px); }
|
||||||
|
.gb-ghost { background:rgba(99,102,241,.08); color:var(--accent-ink); }
|
||||||
|
.gb-ghost:hover { background:rgba(99,102,241,.16); }
|
||||||
|
.gb-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
|
||||||
|
|
||||||
|
.gb-preview { margin-top:18px; padding:18px; border-radius:14px; background:linear-gradient(180deg,#fbfbff,#f4f5fd); border:1px solid rgba(99,102,241,.14); }
|
||||||
|
.gb-preview h3 { font-size:.74rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent-ink); font-weight:800; margin:0 0 10px; }
|
||||||
|
.gb-pv-eq { font-family:'Cambria Math',serif; font-size:1.5rem; color:var(--ink); text-align:center; padding:6px 0 14px; }
|
||||||
|
.gb-pv-ans { text-align:center; color:var(--ok-ink); font-weight:700; margin-bottom:10px; }
|
||||||
|
.gb-pv-step { padding:7px 0; border-top:1px dashed rgba(99,102,241,.2); font-size:.9rem; color:#334155; }
|
||||||
|
.gb-pv-step:first-child { border-top:none; }
|
||||||
|
.gb-pv-step .stx { font-family:'Cambria Math',serif; display:block; margin-top:3px; }
|
||||||
|
.gb-err { background:#fee2e2; color:#b91c1c; border-radius:10px; padding:10px 14px; font-size:.86rem; font-weight:600; margin-top:14px; }
|
||||||
|
.gb-err:empty { display:none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="app-sidebar"></aside>
|
||||||
|
<main class="sb-content">
|
||||||
|
<div class="gb-wrap">
|
||||||
|
<h1 class="gb-h1">Конструктор генераторов</h1>
|
||||||
|
<div class="gb-sub">Создайте параметрический генератор задач: диапазоны → формулы → шаблон → ответ. Сервер проверит, что ответ согласован с условием.</div>
|
||||||
|
|
||||||
|
<div class="gb-grid">
|
||||||
|
<div class="gb-card">
|
||||||
|
<h2>Мои генераторы</h2>
|
||||||
|
<div id="gb-list"><div class="gb-empty">Загрузка…</div></div>
|
||||||
|
<button class="gb-btn gb-ghost" id="gb-new" type="button" style="margin-top:8px;width:100%;justify-content:center">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Новый генератор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-card">
|
||||||
|
<h2 id="gb-form-title">Новый генератор</h2>
|
||||||
|
|
||||||
|
<div class="gb-field"><label>Заголовок</label><input id="f-title" placeholder="напр. Линейное: ax + b = c"/></div>
|
||||||
|
<div class="gb-row2">
|
||||||
|
<div class="gb-field"><label>Тема</label><input id="f-topic" placeholder="custom" value="custom"/></div>
|
||||||
|
<div class="gb-field"><label>Тип</label>
|
||||||
|
<select id="f-kind">
|
||||||
|
<option value="solve">Уравнение (solve)</option>
|
||||||
|
<option value="compute">Вычисление (compute)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-field">
|
||||||
|
<label>Параметры (диапазоны целых)</label>
|
||||||
|
<div class="gb-rows" id="f-pick"></div>
|
||||||
|
<button class="gb-add" id="add-pick" type="button">+ параметр</button>
|
||||||
|
<div class="hint">имя, от, до — напр. a: 2…9. Зарезервированы: x, e, pi, tau.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-field">
|
||||||
|
<label>Производные (формулы от параметров)</label>
|
||||||
|
<div class="gb-rows" id="f-derive"></div>
|
||||||
|
<button class="gb-add" id="add-derive" type="button">+ формула</button>
|
||||||
|
<div class="hint">напр. c = a*root + b. Приём «корень-вперёд»: задайте root и выведите c.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-row2">
|
||||||
|
<div class="gb-field"><label>Левая часть</label><input id="f-lhs" placeholder="{a}*x + {b}"/></div>
|
||||||
|
<div class="gb-field"><label>Правая часть</label><input id="f-rhs" placeholder="{c}"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="gb-field"><label>Условие текстом (для «вычисления»)</label><input id="f-display" placeholder="напр. Найдите {p}% от {a}"/></div>
|
||||||
|
|
||||||
|
<div class="gb-row2">
|
||||||
|
<div class="gb-field"><label>Ответ (формула)</label><input id="f-answer" placeholder="root"/></div>
|
||||||
|
<div class="gb-field"><label>Ограничения (опц.)</label><input id="f-require" placeholder="root != 0"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="gb-field"><label class="gb-check"><input type="checkbox" id="f-int" checked/> Ответ — целое число</label></div>
|
||||||
|
|
||||||
|
<div class="gb-field">
|
||||||
|
<label>Шаги решения</label>
|
||||||
|
<div class="gb-rows" id="f-sol"></div>
|
||||||
|
<button class="gb-add" id="add-sol" type="button">+ шаг</button>
|
||||||
|
<div class="hint">пояснение словами + формула шага (одно равенство), напр. x = {cmb} / {a}.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-actions">
|
||||||
|
<button class="gb-btn gb-ghost" id="gb-preview" type="button">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
Превью
|
||||||
|
</button>
|
||||||
|
<button class="gb-btn gb-primary" id="gb-save" type="button">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<label class="gb-check" style="margin-left:auto"><input type="checkbox" id="f-pub"/> Опубликовать ученикам</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gb-err" id="gb-err"></div>
|
||||||
|
<div class="gb-preview" id="gb-pv" style="display:none">
|
||||||
|
<h3>Превью задачи</h3>
|
||||||
|
<div class="gb-pv-eq" id="pv-eq"></div>
|
||||||
|
<div class="gb-pv-ans" id="pv-ans"></div>
|
||||||
|
<div id="pv-sol"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/mobile.js"></script>
|
||||||
|
<script src="/js/labs/_sim_expr.js"></script>
|
||||||
|
<script src="/js/trainer/_trainer_engine.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (typeof LS === 'undefined') return;
|
||||||
|
var ip = LS.initPage();
|
||||||
|
if (!ip) return;
|
||||||
|
if (!ip.isAdmin) { location.href = '/dashboard'; return; } // конструктор — только админам
|
||||||
|
|
||||||
|
var TE = window.TrainerEngine;
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
function kat(latex, disp) { if (window.katex && latex) { try { return window.katex.renderToString(latex, { displayMode: !!disp, throwOnError: false }); } catch (e) {} } return null; }
|
||||||
|
|
||||||
|
var editingId = null;
|
||||||
|
|
||||||
|
// ── динамические строки ──
|
||||||
|
function pickRow(name, lo, hi) {
|
||||||
|
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||||
|
d.innerHTML = '<input class="nm" placeholder="имя" value="' + esc(name || '') + '"/>' +
|
||||||
|
'<input class="num" type="number" placeholder="от" value="' + (lo == null ? '' : lo) + '"/>' +
|
||||||
|
'<input class="num" type="number" placeholder="до" value="' + (hi == null ? '' : hi) + '"/>' +
|
||||||
|
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||||
|
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function kvRow(name, val, nmPh, valPh) {
|
||||||
|
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||||
|
d.innerHTML = '<input class="nm" placeholder="' + nmPh + '" value="' + esc(name || '') + '"/>' +
|
||||||
|
'<input class="grow" placeholder="' + valPh + '" value="' + esc(val || '') + '"/>' +
|
||||||
|
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||||
|
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function solRow(note, tex) {
|
||||||
|
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||||
|
d.innerHTML = '<input class="grow" placeholder="пояснение" value="' + esc(note || '') + '"/>' +
|
||||||
|
'<input class="grow" placeholder="формула шага" value="' + esc(tex || '') + '" style="font-family:\'Cambria Math\',serif"/>' +
|
||||||
|
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||||
|
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
$('add-pick').addEventListener('click', function () { $('f-pick').appendChild(pickRow()); });
|
||||||
|
$('add-derive').addEventListener('click', function () { $('f-derive').appendChild(kvRow('', '', 'имя', 'формула')); });
|
||||||
|
$('add-sol').addEventListener('click', function () { $('f-sol').appendChild(solRow()); });
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
editingId = null;
|
||||||
|
$('gb-form-title').textContent = 'Новый генератор';
|
||||||
|
$('f-title').value = ''; $('f-topic').value = 'custom'; $('f-kind').value = 'solve';
|
||||||
|
$('f-lhs').value = ''; $('f-rhs').value = ''; $('f-display').value = '';
|
||||||
|
$('f-answer').value = ''; $('f-require').value = ''; $('f-int').checked = true; $('f-pub').checked = false;
|
||||||
|
$('f-pick').innerHTML = ''; $('f-derive').innerHTML = ''; $('f-sol').innerHTML = '';
|
||||||
|
$('f-pick').appendChild(pickRow('a', 2, 9));
|
||||||
|
$('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
|
||||||
|
$('f-sol').appendChild(solRow());
|
||||||
|
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRows(container, mapper) {
|
||||||
|
var out = [];
|
||||||
|
container.querySelectorAll('.gb-rrow').forEach(function (r) {
|
||||||
|
var inputs = r.querySelectorAll('input');
|
||||||
|
var v = mapper(inputs);
|
||||||
|
if (v) out.push(v);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function buildSpec() {
|
||||||
|
var spec = { title: $('f-title').value.trim(), topic: $('f-topic').value.trim() || 'custom', kind: $('f-kind').value };
|
||||||
|
var pick = {};
|
||||||
|
readRows($('f-pick'), function (i) {
|
||||||
|
var nm = i[0].value.trim(); if (!nm) return null;
|
||||||
|
var lo = parseInt(i[1].value, 10), hi = parseInt(i[2].value, 10);
|
||||||
|
if (!isNaN(lo) && !isNaN(hi)) pick[nm] = [lo, hi];
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
spec.pick = pick;
|
||||||
|
var derive = {};
|
||||||
|
readRows($('f-derive'), function (i) { var nm = i[0].value.trim(); if (nm && i[1].value.trim()) derive[nm] = i[1].value.trim(); return null; });
|
||||||
|
if (Object.keys(derive).length) spec.derive = derive;
|
||||||
|
if ($('f-lhs').value.trim()) spec.lhs = $('f-lhs').value.trim();
|
||||||
|
if ($('f-rhs').value.trim()) spec.rhs = $('f-rhs').value.trim();
|
||||||
|
if ($('f-display').value.trim()) spec.display = $('f-display').value.trim();
|
||||||
|
if ($('f-answer').value.trim()) spec.answer = $('f-answer').value.trim();
|
||||||
|
if ($('f-require').value.trim()) spec.require = $('f-require').value.trim();
|
||||||
|
spec.integerAnswer = $('f-int').checked;
|
||||||
|
spec.solution = readRows($('f-sol'), function (i) {
|
||||||
|
var note = i[0].value.trim(), tex = i[1].value.trim();
|
||||||
|
if (!note && !tex) return null;
|
||||||
|
return { note: note, tex: tex };
|
||||||
|
});
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// материализуем спек локально (тот же движок, что у ученика) для превью/валидации
|
||||||
|
function tryInstantiate(spec) {
|
||||||
|
if (!spec.title) return { err: 'Укажите заголовок.' };
|
||||||
|
if (!Object.keys(spec.pick || {}).length) return { err: 'Добавьте хотя бы один параметр.' };
|
||||||
|
try {
|
||||||
|
var p = TE.instantiate(spec, { seed: (Math.random() * 1e9) | 0, strict: true });
|
||||||
|
if (!p) return { err: 'Не удалось сгенерировать задачу — проверьте диапазоны и ограничения.' };
|
||||||
|
return { p: p };
|
||||||
|
} catch (e) {
|
||||||
|
return { err: 'Проверка не прошла: ' + (e && e.message ? e.message : 'ответ не согласован с условием') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
var r = tryInstantiate(buildSpec());
|
||||||
|
if (r.err) { $('gb-err').textContent = r.err; $('gb-pv').style.display = 'none'; return false; }
|
||||||
|
$('gb-err').textContent = '';
|
||||||
|
var p = r.p;
|
||||||
|
var eq = $('pv-eq'); var h = kat(p.latex, true); if (h) eq.innerHTML = h; else eq.textContent = p.display || '—';
|
||||||
|
$('pv-ans').textContent = 'Ответ: ' + (p.answers ? p.answers.join('; ') : ('x = ' + p.answer));
|
||||||
|
$('pv-sol').innerHTML = (p.solution || []).map(function (s, i) {
|
||||||
|
var m = s.latex ? (kat(s.latex, false) || esc(s.tex || '')) : esc(s.tex || '');
|
||||||
|
return '<div class="gb-pv-step">' + (i + 1) + '. ' + esc(s.note || '') + (m ? '<span class="stx">' + m + '</span>' : '') + '</div>';
|
||||||
|
}).join('');
|
||||||
|
$('gb-pv').style.display = 'block';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$('gb-preview').addEventListener('click', renderPreview);
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var spec = buildSpec();
|
||||||
|
var r = tryInstantiate(spec);
|
||||||
|
if (r.err) { $('gb-err').textContent = r.err; return; }
|
||||||
|
var status = $('f-pub').checked ? 'published' : 'draft';
|
||||||
|
var pr = editingId ? LS.practiceGenUpdate(editingId, spec, status) : LS.practiceGenCreate(spec, status);
|
||||||
|
pr.then(function (res) {
|
||||||
|
if (res && res.ok) { if (LS.toast) LS.toast(editingId ? 'Генератор обновлён' : 'Генератор создан', 'success'); editingId = res.generator.dbid; $('gb-form-title').textContent = 'Редактирование'; loadList(); }
|
||||||
|
else { $('gb-err').textContent = 'Не удалось сохранить.'; }
|
||||||
|
}).catch(function (e) { $('gb-err').textContent = 'Ошибка сохранения: ' + (e && e.message || ''); });
|
||||||
|
}
|
||||||
|
$('gb-save').addEventListener('click', save);
|
||||||
|
$('gb-new').addEventListener('click', clearForm);
|
||||||
|
|
||||||
|
function fillForm(g) {
|
||||||
|
editingId = g.dbid;
|
||||||
|
$('gb-form-title').textContent = 'Редактирование: ' + (g.title || '');
|
||||||
|
$('f-title').value = g.title || ''; $('f-topic').value = g.topic || 'custom';
|
||||||
|
$('f-kind').value = (g.kind === 'compute') ? 'compute' : 'solve';
|
||||||
|
$('f-lhs').value = g.lhs || ''; $('f-rhs').value = g.rhs || ''; $('f-display').value = g.display || '';
|
||||||
|
$('f-answer').value = g.answer || ''; $('f-require').value = g.require || ''; $('f-int').checked = !!g.integerAnswer;
|
||||||
|
$('f-pub').checked = g.status === 'published';
|
||||||
|
$('f-pick').innerHTML = ''; var pk = g.pick || {};
|
||||||
|
Object.keys(pk).forEach(function (k) { $('f-pick').appendChild(pickRow(k, pk[k][0], pk[k][1])); });
|
||||||
|
if (!Object.keys(pk).length) $('f-pick').appendChild(pickRow());
|
||||||
|
$('f-derive').innerHTML = ''; var dv = g.derive || {};
|
||||||
|
Object.keys(dv).forEach(function (k) { $('f-derive').appendChild(kvRow(k, dv[k], 'имя', 'формула')); });
|
||||||
|
if (!Object.keys(dv).length) $('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
|
||||||
|
$('f-sol').innerHTML = ''; (g.solution || []).forEach(function (s) { $('f-sol').appendChild(solRow(s.note, s.tex)); });
|
||||||
|
if (!(g.solution || []).length) $('f-sol').appendChild(solRow());
|
||||||
|
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var myId = (LS.getUser && LS.getUser()) ? LS.getUser().id : null;
|
||||||
|
function loadList() {
|
||||||
|
LS.practiceGenList().then(function (r) {
|
||||||
|
var mine = (r.generators || []).filter(function (g) { return g.owner_id === myId; });
|
||||||
|
var box = $('gb-list');
|
||||||
|
if (!mine.length) { box.innerHTML = '<div class="gb-empty">Пока нет генераторов. Создайте первый.</div>'; return; }
|
||||||
|
box.innerHTML = '';
|
||||||
|
mine.forEach(function (g) {
|
||||||
|
var d = document.createElement('div'); d.className = 'gb-list-item';
|
||||||
|
d.innerHTML = '<div class="gb-li-main"><div class="gb-li-title">' + esc(g.title) + '</div><div class="gb-li-meta">' + esc(g.topic) + ' · ' + esc(g.kind || 'solve') + '</div></div>' +
|
||||||
|
'<span class="gb-li-pub ' + (g.status === 'published' ? 'published' : 'draft') + '">' + (g.status === 'published' ? 'опубл.' : 'черновик') + '</span>' +
|
||||||
|
'<button class="gb-icon-btn gb-del" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button>';
|
||||||
|
d.querySelector('.gb-li-main').addEventListener('click', function () { fillForm(g); });
|
||||||
|
d.querySelector('.gb-del').addEventListener('click', function () {
|
||||||
|
LS.practiceGenDelete(g.dbid).then(function () { if (LS.toast) LS.toast('Удалено', 'success'); if (editingId === g.dbid) clearForm(); loadList(); }).catch(function () {});
|
||||||
|
});
|
||||||
|
box.appendChild(d);
|
||||||
|
});
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}).catch(function () { $('gb-list').innerHTML = '<div class="gb-empty">Не удалось загрузить.</div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForm();
|
||||||
|
loadList();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user