Compare commits
62 Commits
0fb16ef85e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,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 fs = require('fs');
|
||||
const path = require('path');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { audit } = require('../utils/audit');
|
||||
const { purgeAccessFor } = require('../services/contentAccess');
|
||||
const sysReset = require('../services/systemReset');
|
||||
|
||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
|
||||
|
||||
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
|
||||
function getAllSessions(req, res) {
|
||||
const { subject, user_id } = req.query;
|
||||
const { subject, user_id, status } = req.query;
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
|
||||
const offset = Math.max(0, Number(req.query.offset) || 0);
|
||||
|
||||
const where = ['ts.status = \'completed\''];
|
||||
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
|
||||
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
|
||||
where.push('ts.status = ?'); params.push(status);
|
||||
}
|
||||
if (subject) { where.push('s.slug = ?'); params.push(subject); }
|
||||
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
|
||||
|
||||
@@ -314,7 +322,7 @@ function getAllSessions(req, res) {
|
||||
FROM test_sessions ts
|
||||
LEFT JOIN subjects s ON s.id = ts.subject_id
|
||||
JOIN users u ON u.id = ts.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY ts.started_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params);
|
||||
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
|
||||
function updateFeatures(req, res) {
|
||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik'];
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
|
||||
const updates = req.body;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||
@@ -586,6 +594,56 @@ function updateFreeStudentFeatures(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
|
||||
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
|
||||
function getResetPlan(req, res) {
|
||||
try {
|
||||
const plan = sysReset.classify(db);
|
||||
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
|
||||
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── POST /api/admin/reset-system ──────────────────────────────────────
|
||||
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
|
||||
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
|
||||
стирает остальных пользователей + активность, переназначает контент. */
|
||||
function resetSystem(req, res) {
|
||||
const confirm = (req.body && req.body.confirm) || '';
|
||||
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
|
||||
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
|
||||
}
|
||||
const keptId = req.user.id;
|
||||
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
|
||||
let backupName = null;
|
||||
try {
|
||||
const dbPath = db._path;
|
||||
if (!dbPath) throw new Error('путь к БД неизвестен');
|
||||
const backupsDir = path.join(path.dirname(dbPath), 'backups');
|
||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
|
||||
const d = new Date();
|
||||
const p2 = n => String(n).padStart(2, '0');
|
||||
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
|
||||
backupName = `learnspace-prereset-${ts}.db`;
|
||||
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
|
||||
}
|
||||
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
|
||||
let summary;
|
||||
try {
|
||||
summary = sysReset.runReset(db, keptId);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
|
||||
}
|
||||
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
|
||||
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
|
||||
res.json({ ok: true, backup: backupName, ...summary });
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
|
||||
function getAuditLog(req, res) {
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
||||
@@ -659,8 +717,6 @@ function clearSecurityLog(req, res) {
|
||||
|
||||
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||
const sse = require('../sse');
|
||||
@@ -879,29 +935,41 @@ function broadcast(req, res) {
|
||||
|
||||
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
|
||||
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: '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: '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' },
|
||||
];
|
||||
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
|
||||
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
|
||||
// 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 = [
|
||||
{ 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 — баланс (1M)', ctx: 1000000, out: 262144 },
|
||||
{ id: 'nex-agi/nex-n2-pro:free', label: 'Nex N2 Pro — чистый русский (262K)', ctx: 262144, 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: 'poolside/laguna-m.1:free', label: 'Laguna M.1 — быстрая (262K)', ctx: 262144, out: 32768 },
|
||||
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS — лёгкая (262K)', ctx: 262144, out: 32768 },
|
||||
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс, быстрый (262K)', ctx: 262144, out: 262144 },
|
||||
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048576, out: 262144 },
|
||||
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман, медленный (1M)', ctx: 1000000, 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-xs.2:free', label: 'Laguna XS.2 — лёгкая, быстрая (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; }
|
||||
|
||||
// Рабочий список бесплатных моделей: обновлённый сканом (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 _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 || ''); }
|
||||
// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен.
|
||||
function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
|
||||
|
||||
function getAssistant(_req, res) {
|
||||
// Миграция legacy-настроек в список провайдеров (один раз)
|
||||
@@ -915,10 +983,10 @@ function getAssistant(_req, res) {
|
||||
}
|
||||
}
|
||||
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 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 };
|
||||
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
|
||||
@@ -939,8 +1007,11 @@ function getAssistant(_req, res) {
|
||||
res.json({
|
||||
providers, activeId, active,
|
||||
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
||||
memory: _aset('assistant_memory') !== '0',
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
|
||||
memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1',
|
||||
healthEnabled: _aset('assistant_health_enabled') !== '0',
|
||||
health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(),
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
|
||||
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -951,6 +1022,8 @@ function saveAssistant(req, res) {
|
||||
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.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 (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
|
||||
audit(req, 'assistant.config', 'assistant', 'настройки');
|
||||
res.json({ ok: true });
|
||||
@@ -1050,6 +1123,105 @@ async function getProviderModels(req, res) {
|
||||
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 } — выбрать активного провайдера */
|
||||
function setActiveProvider(req, res) {
|
||||
const id = String((req.body && req.body.id) || '');
|
||||
@@ -1087,7 +1259,7 @@ async function testAssistant(req, res) {
|
||||
};
|
||||
}
|
||||
override.local = _aIsLocal(override.url);
|
||||
override.on = !!(override.key || override.local);
|
||||
override.on = !!(override.key || _aNoKey(override.url));
|
||||
const r = await a.pingLLM(override);
|
||||
// Успешный тест активного провайдера снимает устаревший флаг failover
|
||||
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
|
||||
@@ -1157,9 +1329,11 @@ module.exports = {
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getResetPlan, resetSystem,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
broadcast,
|
||||
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
|
||||
scanModels, probeModel, applyModels, runHealth,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ const db = require('../db/db');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { SESSION_MODES } = require('../constants');
|
||||
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
|
||||
|
||||
const VALID_ASSIGN_MODES = SESSION_MODES;
|
||||
|
||||
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
|
||||
function myAssignments(req, res) {
|
||||
const uid = req.user.id;
|
||||
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
|
||||
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
|
||||
function assignmentRowsForUser(uid) {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
|
||||
@@ -267,6 +268,7 @@ function myAssignments(req, res) {
|
||||
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
|
||||
tp.paragraphs_read AS textbook_read,
|
||||
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
|
||||
a.created_by AS created_by,
|
||||
latest.session_id,
|
||||
ts.score, ts.total, ts.status AS session_status,
|
||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
||||
@@ -295,6 +297,7 @@ function myAssignments(req, res) {
|
||||
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
|
||||
tp.paragraphs_read AS textbook_read,
|
||||
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
|
||||
a.created_by AS created_by,
|
||||
latest.session_id,
|
||||
ts.score, ts.total, ts.status AS session_status,
|
||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
||||
@@ -334,8 +337,78 @@ function myAssignments(req, res) {
|
||||
// Strip raw paragraphs_read JSON from response (not needed by client)
|
||||
delete r.textbook_read;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
|
||||
function myAssignments(req, res) {
|
||||
res.json(assignmentRowsForUser(req.user.id));
|
||||
}
|
||||
|
||||
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
|
||||
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
|
||||
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
|
||||
function classOutstanding(req, res) {
|
||||
const cid = req.params.id;
|
||||
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
|
||||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.name, u.email FROM class_members cm
|
||||
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
|
||||
`).all(cid);
|
||||
|
||||
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
|
||||
const subRows = db.prepare(`
|
||||
SELECT s.assignment_id, s.student_id, s.status
|
||||
FROM submissions s
|
||||
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
|
||||
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
|
||||
`).all(cid);
|
||||
const subMap = new Map();
|
||||
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
|
||||
|
||||
const now = Date.now();
|
||||
const cidNum = Number(cid);
|
||||
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
|
||||
|
||||
const students = members.map(m => {
|
||||
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
|
||||
const rows = assignmentRowsForUser(m.id).filter(r =>
|
||||
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
|
||||
);
|
||||
const pending = [];
|
||||
for (const r of rows) {
|
||||
const t = AssignmentUtils.type(r);
|
||||
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
|
||||
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
|
||||
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
|
||||
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
|
||||
let status = overdue ? 'overdue' : 'not_started';
|
||||
if (st === 'revision') status = 'revision'; // вернули на доработку
|
||||
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
|
||||
pending.push({
|
||||
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
|
||||
status, is_homework: r.is_homework ? 1 : 0,
|
||||
scope: r.class_id === cidNum ? 'class' : 'direct',
|
||||
});
|
||||
}
|
||||
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
|
||||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
|
||||
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
|
||||
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
|
||||
pending.forEach(p => { counts[p.status]++; });
|
||||
return { id: m.id, name: m.name, email: m.email, pending, counts };
|
||||
});
|
||||
|
||||
const summary = {
|
||||
students_total: members.length,
|
||||
debtors: students.filter(s => s.counts.total > 0).length,
|
||||
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
|
||||
};
|
||||
res.json({ className: cls.name, summary, students });
|
||||
}
|
||||
|
||||
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
|
||||
@@ -732,6 +805,7 @@ module.exports = {
|
||||
deleteAssignment,
|
||||
teacherAssignments,
|
||||
myAssignments,
|
||||
classOutstanding,
|
||||
startAssignment,
|
||||
assignmentResults,
|
||||
assignmentQuestionStats,
|
||||
|
||||
@@ -339,6 +339,8 @@ function searchFaq(q, n) {
|
||||
* на 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 _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.
|
||||
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
|
||||
@@ -357,7 +359,7 @@ function _providers() {
|
||||
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
|
||||
* (для авто-перехвата при лимите/ошибке). */
|
||||
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 active = arr.filter(p => p.id === activeId);
|
||||
const rest = arr.filter(p => p.id !== activeId);
|
||||
@@ -451,11 +453,69 @@ async function callLLMFailover(messages, maxTokens) {
|
||||
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/ошибка/пример ответа). */
|
||||
async function pingLLM(override) {
|
||||
const cfg = override || llmConfig();
|
||||
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 недоступен' };
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
@@ -496,7 +556,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');
|
||||
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 user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
|
||||
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
|
||||
@@ -510,15 +575,33 @@ async function askModel(q, hits, context, history, role, mode, mem) {
|
||||
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
|
||||
} else if (mode === 'check') {
|
||||
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
|
||||
} else if (socratic) {
|
||||
// Сократический режим (для учеников): теория — полно, но задачи не решаем «под ключ».
|
||||
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) }); });
|
||||
msgs.push({ role: 'user', content: user });
|
||||
// подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине
|
||||
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);
|
||||
}
|
||||
|
||||
// Сократический режим включается для УЧЕНИКА: если включён тумблер ИЛИ явная просьба «сделай за меня».
|
||||
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? } ── «Спроси Квантика» ─
|
||||
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
|
||||
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
|
||||
@@ -551,8 +634,9 @@ async function ask(req, res) {
|
||||
let context = pageCtx;
|
||||
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
||||
|
||||
const socratic = _socraticFor(req.user && req.user.role, mode, q);
|
||||
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;
|
||||
|
||||
if (answer) {
|
||||
@@ -572,6 +656,66 @@ async function ask(req, res) {
|
||||
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(-6) : [];
|
||||
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 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? } ── лайк/дизлайк ответа ── */
|
||||
function feedback(req, res) {
|
||||
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
|
||||
@@ -621,4 +765,50 @@ async function flashcardsFromText(req, res) {
|
||||
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); }
|
||||
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 };
|
||||
|
||||
@@ -41,6 +41,15 @@ function createSession(req, res) {
|
||||
|
||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
|
||||
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
|
||||
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
|
||||
try {
|
||||
const sse = require('../../sse');
|
||||
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
|
||||
if (class_id) sse.emitToClass(class_id, payload);
|
||||
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
|
||||
sse.emit(teacher.id, payload);
|
||||
} catch { /* SSE недоступен — не критично */ }
|
||||
res.json(session);
|
||||
}
|
||||
|
||||
@@ -74,6 +83,17 @@ function endSession(req, res) {
|
||||
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
||||
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
||||
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
|
||||
try {
|
||||
const sse = require('../../sse');
|
||||
const payload = { type: 'classroom_live', state: 'ended', sessionId };
|
||||
if (session.class_id) sse.emitToClass(session.class_id, payload);
|
||||
else {
|
||||
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
for (const r of invited) sse.emit(r.user_id, payload);
|
||||
}
|
||||
sse.emit(session.teacher_id, payload);
|
||||
} catch { /* SSE недоступен — не критично */ }
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
/* clientErrorController — приём ошибок из браузера пользователя.
|
||||
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
|
||||
Запись не должна ронять запрос — любые сбои глушим. */
|
||||
const db = require('../db/db');
|
||||
|
||||
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
|
||||
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
|
||||
|
||||
function report(req, res) {
|
||||
const b = req.body || {};
|
||||
const message = (clamp(b.message, MAX_MSG) || '').trim();
|
||||
if (!message) return res.status(400).json({ error: 'message required' });
|
||||
|
||||
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
|
||||
const route = clamp(b.url || b.route, MAX_ROUTE);
|
||||
let stack = clamp(b.stack, MAX_STACK);
|
||||
// если стека нет — собираем источник:строка:колонка
|
||||
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('client', message, stack, route, kind, req.user.id);
|
||||
} catch { /* лог не должен ломать ответ */ }
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { report };
|
||||
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
|
||||
}
|
||||
|
||||
function onLabExperiment(userId, reactionsDiscovered) {
|
||||
if (!isGamificationEnabled()) return; // master kill-switch
|
||||
stmts.incrLabExp.run(userId);
|
||||
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
|
||||
awardXP(userId, 15, 'lab_experiment');
|
||||
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
|
||||
}
|
||||
|
||||
function updateChallenges(userId, score, total, subjectSlug, topicId) {
|
||||
if (!isGamificationEnabled()) return; // master kill-switch
|
||||
const week = _currentWeek();
|
||||
const pct = total > 0 ? Math.round(score / total * 100) : 0;
|
||||
const challenges = stmts.getOpenChallenges.all(userId, week);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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 ─────────────────────────────────────────────────────── */
|
||||
function list(req, res) {
|
||||
const { subject } = req.query;
|
||||
@@ -7,13 +19,16 @@ function list(req, res) {
|
||||
const args = [];
|
||||
let where = '1=1';
|
||||
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
|
||||
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
|
||||
const isStudent = role === 'student' || role === 'free_student';
|
||||
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
|
||||
if (isStudent) { where += ' AND t.available_to_students = 1'; }
|
||||
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
|
||||
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
|
||||
// не показываем их во вкладке «Тесты (шаблоны)» админки.
|
||||
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
|
||||
let rows = db.prepare(`
|
||||
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
|
||||
u.name AS creator_name,
|
||||
COUNT(tq.question_id) AS question_count
|
||||
FROM tests t
|
||||
@@ -22,18 +37,19 @@ function list(req, res) {
|
||||
WHERE ${where}
|
||||
GROUP BY t.id ORDER BY t.created_at DESC
|
||||
`).all(...args);
|
||||
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
|
||||
function create(req, res) {
|
||||
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
|
||||
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
|
||||
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
|
||||
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
|
||||
const r = db.prepare(
|
||||
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
|
||||
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
|
||||
res.status(201).json({ id: r.lastInsertRowid });
|
||||
}
|
||||
|
||||
@@ -76,13 +92,23 @@ function getOne(req, res) {
|
||||
|
||||
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
|
||||
function update(req, res) {
|
||||
const { title, subject_slug, description, show_answers, time_limit } = req.body;
|
||||
const t = req.resource; // ownership verified by requireOwnership middleware
|
||||
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
|
||||
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
|
||||
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
|
||||
tl !== undefined ? tl : t.time_limit,
|
||||
t.id);
|
||||
const b = req.body;
|
||||
const t = req.resource; // ownership verified by requireOwnership middleware
|
||||
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
|
||||
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
|
||||
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
|
||||
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
|
||||
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
|
||||
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
|
||||
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
|
||||
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
|
||||
// Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа.
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
const db = require('../db/db');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
|
||||
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
|
||||
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
const STATUS_LABEL = {
|
||||
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
|
||||
done: 'Готово', declined: 'Отклонено',
|
||||
};
|
||||
|
||||
function clampStr(v, max) {
|
||||
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
|
||||
}
|
||||
|
||||
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
|
||||
function list(req, res) {
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const { status, category } = req.query;
|
||||
const where = [];
|
||||
const args = [];
|
||||
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
|
||||
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
|
||||
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
|
||||
const sql = `
|
||||
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
|
||||
w.created_at, w.updated_at,
|
||||
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
|
||||
0 AS _pad
|
||||
FROM wishes w
|
||||
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
|
||||
w.updated_at DESC`;
|
||||
const rows = db.prepare(sql).all(...args);
|
||||
|
||||
let counts = null;
|
||||
if (isAdmin) {
|
||||
counts = {};
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
|
||||
}
|
||||
res.json({ wishes: rows, counts, isAdmin });
|
||||
}
|
||||
|
||||
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
|
||||
function create(req, res) {
|
||||
const title = clampStr(req.body?.title, 200);
|
||||
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
|
||||
const body = clampStr(req.body?.body, 4000);
|
||||
let category = String(req.body?.category || 'other');
|
||||
if (!CATEGORIES.includes(category)) category = 'other';
|
||||
|
||||
const info = db.prepare(
|
||||
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
|
||||
).run(req.user.id, title, body || null, category);
|
||||
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
|
||||
res.status(201).json(row);
|
||||
}
|
||||
|
||||
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
|
||||
function update(req, res) {
|
||||
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
|
||||
const fields = [];
|
||||
const args = [];
|
||||
let newStatus = null;
|
||||
if (req.body?.status !== undefined) {
|
||||
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
|
||||
if (req.body.status !== wish.status) newStatus = req.body.status;
|
||||
fields.push('status = ?'); args.push(req.body.status);
|
||||
}
|
||||
if (req.body?.admin_note !== undefined) {
|
||||
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
|
||||
}
|
||||
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
|
||||
|
||||
// Уведомить автора при смене статуса (durable + SSE).
|
||||
if (newStatus && wish.user_id !== req.user.id) {
|
||||
try {
|
||||
pushNotif(wish.user_id, 'wish_update',
|
||||
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
|
||||
} catch { /* notif не критичен */ }
|
||||
}
|
||||
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
|
||||
}
|
||||
|
||||
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
|
||||
function remove(req, res) {
|
||||
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwner = wish.user_id === req.user.id;
|
||||
if (!isAdmin && !(isOwner && wish.status === 'new'))
|
||||
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
|
||||
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
|
||||
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
|
||||
};
|
||||
};
|
||||
|
||||
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
|
||||
module.exports = db;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
|
||||
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
|
||||
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
|
||||
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
|
||||
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
|
||||
CREATE TABLE IF NOT EXISTS wishes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
|
||||
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
|
||||
admin_note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
|
||||
@@ -119,6 +119,22 @@ function parentAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям
|
||||
* ученика (student/free_student); учитель и админ проходят всегда.
|
||||
* Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент,
|
||||
* материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать
|
||||
* доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false).
|
||||
*/
|
||||
function requirePermissionForStudents(key) {
|
||||
const guard = requirePermission(key);
|
||||
return (req, res, next) => {
|
||||
const r = req.user?.role;
|
||||
if (r === 'student' || r === 'free_student') return guard(req, res, next);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/* Alias: requireAuth = authMiddleware */
|
||||
const requireAuth = authMiddleware;
|
||||
|
||||
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
|
||||
|
||||
@@ -115,6 +115,28 @@ const PERMISSIONS = {
|
||||
label: 'Управление геймификацией',
|
||||
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
|
||||
},
|
||||
'classroom.host': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Вести онлайн-уроки',
|
||||
desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'livequiz.host': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Запускать живые викторины',
|
||||
desc: 'Создавать и проводить синхронные викторины в реальном времени',
|
||||
},
|
||||
'simbuilder.use': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Конструктор симуляций',
|
||||
desc: 'Создавать и редактировать собственные интерактивные симуляции',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'flashcards.manage': {
|
||||
role: 'teacher', roles: ['teacher'], default: 1,
|
||||
label: 'Общие колоды флеш-карт',
|
||||
desc: 'Создавать и раздавать общие колоды флеш-карт классам',
|
||||
},
|
||||
|
||||
/* ── Student (also applies to free_student — same keys, same defaults) ── */
|
||||
'tests.free': {
|
||||
@@ -160,6 +182,38 @@ const PERMISSIONS = {
|
||||
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
||||
requires: ['simulations.access'],
|
||||
},
|
||||
'homework.submit': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Сдавать домашние задания',
|
||||
desc: 'Загружать работы и пересдавать домашние задания',
|
||||
},
|
||||
'materials.save': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Сохранять материалы',
|
||||
desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»',
|
||||
},
|
||||
'assistant.use': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'ИИ-ассистент',
|
||||
desc: 'Задавать вопросы ИИ-ассистенту «Квантик»',
|
||||
},
|
||||
'flashcards.access': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Раздел флеш-карт доступен роли',
|
||||
desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'exam.access': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Подготовка к экзаменам доступна роли',
|
||||
desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»',
|
||||
requireConfirmOff: true,
|
||||
},
|
||||
'games.play': {
|
||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||
label: 'Учебные игры',
|
||||
desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)',
|
||||
},
|
||||
};
|
||||
|
||||
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
|
||||
@@ -169,15 +223,20 @@ const GROUP = {
|
||||
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
|
||||
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
|
||||
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
|
||||
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
|
||||
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
|
||||
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
|
||||
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
|
||||
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
|
||||
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
|
||||
// student
|
||||
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
|
||||
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
|
||||
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
|
||||
'profile.edit': 'Профиль',
|
||||
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
|
||||
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
|
||||
'flashcards.access': 'Контент', 'exam.access': 'Контент',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,19 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
|
||||
/* Everything below is admin-only */
|
||||
router.use(requireRole('admin'));
|
||||
|
||||
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
|
||||
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
|
||||
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
|
||||
|
||||
router.get('/assistant', ctrl.getAssistant);
|
||||
router.put('/assistant', ctrl.saveAssistant);
|
||||
router.post('/assistant/test', ctrl.testAssistant);
|
||||
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
||||
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.get('/imggen', ctrl.getImggen);
|
||||
router.put('/imggen', ctrl.saveImggen);
|
||||
router.post('/imggen/test', ctrl.testImggen);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
|
||||
* 'pet' навешивается при монтировании в server.js. */
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const ctrl = require('../controllers/assistantController');
|
||||
|
||||
@@ -16,8 +16,10 @@ router.get('/context', ctrl.getContext);
|
||||
router.post('/seen', ctrl.markSeen);
|
||||
router.post('/dismiss', ctrl.dismiss);
|
||||
router.patch('/settings', ctrl.setSettings);
|
||||
router.post('/ask', askLimiter, ctrl.ask);
|
||||
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
|
||||
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
|
||||
router.post('/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.get('/memory', ctrl.getMemory);
|
||||
router.delete('/memory', ctrl.clearMemory);
|
||||
|
||||
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
|
||||
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
|
||||
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
|
||||
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
|
||||
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
|
||||
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
|
||||
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
|
||||
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
|
||||
|
||||
@@ -2,7 +2,7 @@ const router = require('express').Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const c = require('../controllers/classroomController');
|
||||
|
||||
@@ -47,7 +47,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
|
||||
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
||||
|
||||
// Session lifecycle
|
||||
router.post('/', ...teacher, c.createSession);
|
||||
router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
|
||||
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
||||
router.get('/my/session', ...auth, c.getMySession);
|
||||
router.get('/class/:classId/active', ...auth, c.getActiveSession);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const rateLimit = require('../middleware/rateLimit');
|
||||
const ctrl = require('../controllers/clientErrorController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
|
||||
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,7 +5,7 @@
|
||||
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const { requireFeature } = require('../middleware/features');
|
||||
const c = require('../controllers/customSimController');
|
||||
|
||||
@@ -22,9 +22,9 @@ router.get('/:id', c.get);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id/related', c.related);
|
||||
|
||||
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
|
||||
router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
|
||||
router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
|
||||
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
|
||||
router.use(requirePermissionForStudents('exam.access'));
|
||||
|
||||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
@@ -59,6 +62,8 @@ const VARIANT_LABEL = {
|
||||
117: 'ЦТ-2021',
|
||||
118: 'ЦТ-2017',
|
||||
119: 'ЦТ-2013',
|
||||
120: 'ЦТ-2012',
|
||||
121: 'ЦТ-2011',
|
||||
},
|
||||
};
|
||||
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
|
||||
|
||||
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const fc = require('../controllers/flashcardController');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const { requireOwnership } = require('../middleware/ownership');
|
||||
|
||||
/* ── multer для картинок карточек ───────────────────────────────────────
|
||||
@@ -30,6 +30,9 @@ const fcUpload = multer({
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
|
||||
// учитель/админ проходят всегда (создают и раздают колоды).
|
||||
router.use(requirePermissionForStudents('flashcards.access'));
|
||||
|
||||
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
|
||||
|
||||
@@ -45,8 +48,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
||||
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
||||
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
|
||||
router.get ('/decks/:id/shares', fc.listShares);
|
||||
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
|
||||
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
|
||||
router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
|
||||
router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
|
||||
router.get ('/decks/:id/study', fc.getStudySession);
|
||||
router.put ('/cards/:id', fc.updateCard);
|
||||
router.delete('/cards/:id', fc.deleteCard);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const { requireFeature } = require('../middleware/features');
|
||||
const c = require('../controllers/gamesController');
|
||||
|
||||
const hangman = requireFeature('hangman');
|
||||
const crossword = requireFeature('crossword');
|
||||
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
|
||||
const playable = requirePermissionForStudents('games.play');
|
||||
|
||||
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord);
|
||||
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete);
|
||||
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate);
|
||||
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete);
|
||||
router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
|
||||
router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
|
||||
router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
|
||||
router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const c = require('../controllers/liveController');
|
||||
|
||||
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
|
||||
|
||||
router.post('/', ...teacher, c.create);
|
||||
router.post('/', ...teacher, requirePermission('livequiz.host'), c.create);
|
||||
router.get('/:id', ...teacher, c.getSession);
|
||||
router.put('/:id/question', ...teacher, c.setQuestion);
|
||||
router.get('/:id/results', ...teacher, c.results);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
|
||||
const c = require('../controllers/studentMaterialsController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
@@ -10,7 +10,8 @@ router.use(authMiddleware);
|
||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||
|
||||
router.get('/', c.list);
|
||||
router.post('/', c.create);
|
||||
// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
|
||||
router.post('/', requirePermissionForStudents('materials.save'), c.create);
|
||||
|
||||
// Collections (folders) — literal '/collections' prefix before '/:id'
|
||||
router.post('/collections', c.createCollection);
|
||||
|
||||
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
|
||||
|
||||
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
|
||||
const { default_mode, default_count, default_test_id } = req.body;
|
||||
const valid_modes = ['exam', 'practice', 'topic', 'random'];
|
||||
// Старт сессии (POST /api/sessions) поддерживает только exam/practice — раньше тут
|
||||
// допускались topic/random, но клик по такому предмету на дашборде падал с 400.
|
||||
const valid_modes = ['exam', 'practice'];
|
||||
if (default_mode && !valid_modes.includes(default_mode))
|
||||
return res.status(400).json({ error: 'Invalid mode' });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const router = require('express').Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/submissionsController');
|
||||
const { fixUtf8Name } = require('../utils/fixUtf8');
|
||||
|
||||
@@ -47,7 +47,7 @@ const upload = multer({
|
||||
/* ── routes ─────────────────────────────────────────────────────────── */
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
|
||||
router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit);
|
||||
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
|
||||
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
|
||||
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
|
||||
@@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm
|
||||
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
|
||||
router.get('/:id/download', ctrl.downloadSubmission);
|
||||
router.delete('/:id', ctrl.deleteSubmission);
|
||||
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
|
||||
router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/wishController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
|
||||
router.post('/', ctrl.create); // любой авторизованный
|
||||
|
||||
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
|
||||
router.patch('/:id', requireRole('admin'), ctrl.update);
|
||||
router.delete('/:id', ctrl.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -198,6 +198,8 @@ app.use('/api/lab', labRoutes);
|
||||
app.use('/api/materials', require('./routes/materials'));
|
||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||
app.use('/api/game', require('./routes/game'));
|
||||
app.use('/api/wishes', require('./routes/wishes'));
|
||||
app.use('/api/client-errors', require('./routes/clientErrors'));
|
||||
app.use('/api/prep', require('./routes/prep'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
|
||||
@@ -533,6 +535,9 @@ require('./ws-server').attach(server);
|
||||
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
|
||||
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 ── */
|
||||
function shutdown(signal) {
|
||||
logger.info(`${signal} received — shutting down gracefully`);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
systemReset.js — общая логика «чистого запуска» (используют и CLI
|
||||
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
|
||||
|
||||
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
|
||||
|
||||
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
|
||||
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
|
||||
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
|
||||
const REASSIGN = {
|
||||
courses: 'created_by', tests: 'created_by',
|
||||
flashcard_decks: 'user_id', custom_sims: 'owner_id',
|
||||
course_templates: 'created_by', lesson_templates: 'created_by',
|
||||
assignment_templates: 'created_by', lab_sim_links: 'created_by',
|
||||
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
|
||||
};
|
||||
|
||||
/* Активность/организация — полностью очищается. */
|
||||
const WIPE = new Set([
|
||||
'test_sessions', 'session_questions', 'user_answers',
|
||||
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
|
||||
'assignments', 'assignment_sessions', 'assignment_completion',
|
||||
'submissions', 'submission_log',
|
||||
'classes', 'class_members', 'class_courses',
|
||||
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
|
||||
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
|
||||
'classroom_notes', 'classroom_pages', 'classroom_strokes',
|
||||
'live_sessions', 'live_answers',
|
||||
'content_access',
|
||||
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
|
||||
'notifications', 'parent_notifications', 'parent_links',
|
||||
'student_materials', 'material_collections',
|
||||
'game_progress',
|
||||
'lesson_progress', 'lesson_comments', 'lesson_notes',
|
||||
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
|
||||
'flashcard_reviews', 'flashcard_deck_access',
|
||||
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
|
||||
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
|
||||
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
|
||||
'imggen_usage',
|
||||
'folder_access', 'file_access',
|
||||
'avatar_requests',
|
||||
'geometry_submissions', 'geometry_tasks',
|
||||
'security_events', 'error_log', 'admin_audit_log',
|
||||
'student_prep',
|
||||
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
|
||||
]);
|
||||
|
||||
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
|
||||
const KEEP = new Set([
|
||||
'subjects', 'questions', 'options', 'topics',
|
||||
'textbooks', 'textbook_chunks',
|
||||
'lessons', 'lesson_blocks', 'course_sections',
|
||||
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
|
||||
'test_questions', 'flashcard_cards', 'lab_sims',
|
||||
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
|
||||
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
|
||||
'shop_items', 'achievements',
|
||||
'roles', 'role_permissions', 'app_settings', '_migrations',
|
||||
]);
|
||||
|
||||
const ADMIN_RESET_SQL =
|
||||
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
|
||||
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
|
||||
pet_petting_streak = 0 WHERE id = ?`;
|
||||
|
||||
function allTables(db) {
|
||||
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all().map(r => r.name);
|
||||
}
|
||||
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
|
||||
|
||||
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
|
||||
function pickKeptAdmin(db) {
|
||||
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
|
||||
}
|
||||
|
||||
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
|
||||
function classify(db) {
|
||||
const tables = allTables(db);
|
||||
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
|
||||
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
|
||||
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
|
||||
const totalUsers = rowCount(db, 'users');
|
||||
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
|
||||
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
|
||||
* которого сохраняем (ему переназначается контент). Возвращает сводку.
|
||||
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
|
||||
*/
|
||||
function runReset(db, keptAdminId) {
|
||||
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
|
||||
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [t, col] of Object.entries(REASSIGN)) {
|
||||
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
|
||||
}
|
||||
for (const t of WIPE) {
|
||||
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
|
||||
}
|
||||
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
|
||||
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
|
||||
db.exec('COMMIT');
|
||||
var deletedUsers = del.changes;
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
throw e;
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
let fkBad = 0;
|
||||
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
|
||||
try { db.exec('VACUUM'); } catch {}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
keptAdminId,
|
||||
deletedUsers,
|
||||
remainingUsers: rowCount(db, 'users'),
|
||||
fkDangling: fkBad,
|
||||
kept: {
|
||||
textbooks: rowCount(db, 'textbooks'),
|
||||
questions: rowCount(db, 'questions'),
|
||||
tests: rowCount(db, 'tests'),
|
||||
courses: rowCount(db, 'courses'),
|
||||
exam_tasks: rowCount(db, 'exam_tasks'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, db, cleanup } = require('./setup');
|
||||
|
||||
app.use('/api/client-errors', require('../src/routes/clientErrors'));
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/client-errors', () => {
|
||||
let student;
|
||||
before(async () => { student = await getToken('student'); });
|
||||
|
||||
it('требует авторизацию (401)', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('пустое сообщение → 400', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('пишет ошибку в error_log с level=client', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
kind: 'error', message: 'TypeError: x is null',
|
||||
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
|
||||
url: '/lab?sim=demo#x',
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
|
||||
).get(student.userId);
|
||||
assert.ok(row, 'строка должна появиться');
|
||||
assert.equal(row.message, 'TypeError: x is null');
|
||||
assert.equal(row.route, '/lab?sim=demo#x');
|
||||
assert.equal(row.method, 'error');
|
||||
assert.match(row.stack, /app\.js:10:5/);
|
||||
});
|
||||
|
||||
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
|
||||
).get();
|
||||
assert.equal(row.method, 'rejection');
|
||||
assert.match(row.stack, /x\.js:3:1/);
|
||||
});
|
||||
|
||||
it('длинные поля обрезаются (не падает)', async () => {
|
||||
const res = await inject('POST', '/api/client-errors', {
|
||||
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
|
||||
}, student.token);
|
||||
assert.equal(res.status, 200);
|
||||
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
|
||||
assert.ok(row.message.length <= 1000);
|
||||
assert.ok(row.stack.length <= 4000);
|
||||
assert.ok(row.route.length <= 400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
|
||||
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
|
||||
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
|
||||
* (автор «новое» / админ; чужое нельзя).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
app.use('/api/wishes', require('../src/routes/wishes'));
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/wishes', () => {
|
||||
let s1, s2, admin;
|
||||
|
||||
before(async () => {
|
||||
s1 = await getToken('student');
|
||||
s2 = await getToken('student');
|
||||
admin = await getToken('admin');
|
||||
});
|
||||
|
||||
it('POST /wishes requires auth (401)', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('создание: пустой заголовок → 400', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
let wishId;
|
||||
it('создание пожелания учеником → 201, статус new', async () => {
|
||||
const res = await inject('POST', '/api/wishes',
|
||||
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'new');
|
||||
assert.equal(res.body.category, 'ui');
|
||||
assert.equal(res.body.user_id, s1.userId);
|
||||
wishId = res.body.id;
|
||||
});
|
||||
|
||||
it('неизвестная категория → other', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
|
||||
assert.equal(res.body.category, 'other');
|
||||
});
|
||||
|
||||
it('приватность: автор видит свои', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.equal(res.body.isAdmin, false);
|
||||
});
|
||||
|
||||
it('приватность: другой ученик НЕ видит чужое', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s2.token);
|
||||
assert.ok(!res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('админ видит все + counts', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.isAdmin, true);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
|
||||
// у админа в списке есть имя автора
|
||||
const w = res.body.wishes.find(x => x.id === wishId);
|
||||
assert.ok(w.author_name);
|
||||
});
|
||||
|
||||
it('триаж учеником запрещён (403)', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('админ меняет статус + ответ → 200', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`,
|
||||
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'planned');
|
||||
assert.equal(res.body.admin_note, 'Запланировано на лето');
|
||||
});
|
||||
|
||||
it('неверный статус → 400', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('фильтр по статусу у админа', async () => {
|
||||
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
|
||||
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('чужой ученик не может удалить → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('автор удаляет своё «новое» → 200', async () => {
|
||||
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
|
||||
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it('админ удаляет любое → 200', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
const gone = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
});
|
||||
+11
-4
@@ -486,6 +486,13 @@
|
||||
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
|
||||
.tst-search:focus { border-color: var(--violet); }
|
||||
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
|
||||
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
|
||||
.tst-pick-sel:focus { border-color: var(--violet); }
|
||||
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
|
||||
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
|
||||
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
|
||||
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
|
||||
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
/* formula bar */
|
||||
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
|
||||
@@ -1071,7 +1078,7 @@
|
||||
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
|
||||
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
|
||||
<i data-lucide="layout-grid" style="width:15px;height:15px"></i> Модули
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
||||
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
|
||||
@@ -1568,10 +1575,10 @@
|
||||
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Игры ── -->
|
||||
<!-- ── Модули ── -->
|
||||
<div class="tab-pane" id="tab-games">
|
||||
<div class="section-title">Управление играми</div>
|
||||
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
||||
<div class="section-title">Управление модулями</div>
|
||||
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
|
||||
<div class="perm-grid" id="games-features-grid">
|
||||
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
|
||||
</div>
|
||||
|
||||
+127
-1
@@ -109,6 +109,29 @@
|
||||
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||||
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||||
|
||||
/* ── Долги (что висит у учеников) ── */
|
||||
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
|
||||
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
|
||||
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
|
||||
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
|
||||
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.debt-name { font-size: 0.9rem; font-weight: 700; }
|
||||
.debt-email { font-size: 0.74rem; color: var(--text-3); }
|
||||
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
|
||||
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
|
||||
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
|
||||
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
|
||||
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
|
||||
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
|
||||
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
|
||||
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
|
||||
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
|
||||
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
|
||||
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||||
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
|
||||
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
|
||||
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
|
||||
|
||||
/* ── Student search ── */
|
||||
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
|
||||
.student-search-wrap .form-input { width: 100%; }
|
||||
@@ -628,6 +651,7 @@
|
||||
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
|
||||
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
|
||||
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
|
||||
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
|
||||
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
|
||||
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
|
||||
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
|
||||
@@ -667,6 +691,11 @@
|
||||
<div class="assign-list" id="d-assignments"></div>
|
||||
</div>
|
||||
|
||||
<!-- Debts (что висит у учеников) -->
|
||||
<div class="tab-pane" id="dtab-debts">
|
||||
<div id="debts-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Journal -->
|
||||
<div class="tab-pane" id="dtab-journal">
|
||||
<div id="journal-content"><div class="spinner"></div></div>
|
||||
@@ -794,7 +823,7 @@
|
||||
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
|
||||
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
|
||||
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
|
||||
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
|
||||
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Загрузка работы</button>
|
||||
</div>
|
||||
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
|
||||
Вопросы подбираются случайно из базы по выбранному предмету
|
||||
@@ -1199,6 +1228,102 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
|
||||
const DEBT_STATUS = {
|
||||
overdue: { label: 'просрочено', cls: 'dc-overdue' },
|
||||
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
|
||||
revision: { label: 'на доработке', cls: 'dc-revision' },
|
||||
not_started: { label: 'не начато', cls: 'dc-not_started' },
|
||||
};
|
||||
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
|
||||
let _debtData = null;
|
||||
|
||||
async function loadDebts() {
|
||||
if (!currentClass) return;
|
||||
const el = document.getElementById('debts-content');
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
_debtData = await LS.classOutstanding(currentClass.id);
|
||||
renderDebts();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function debtChips(c) {
|
||||
return ['overdue', 'in_progress', 'revision', 'not_started']
|
||||
.filter(k => c[k] > 0)
|
||||
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function debtItemHtml(s, p) {
|
||||
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
|
||||
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
|
||||
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
|
||||
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
|
||||
return `<div class="debt-item">
|
||||
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="debt-item-title">${esc(p.title)}</div>
|
||||
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
|
||||
</div>
|
||||
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDebts() {
|
||||
const el = document.getElementById('debts-content');
|
||||
if (!_debtData) { el.innerHTML = ''; return; }
|
||||
const { summary, students } = _debtData;
|
||||
const withDebt = students.filter(s => s.counts.total > 0);
|
||||
const badge = document.getElementById('debts-tab-badge');
|
||||
if (badge) {
|
||||
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
|
||||
else badge.style.display = 'none';
|
||||
}
|
||||
if (!withDebt.length) {
|
||||
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
let html = `<div class="debt-summary">
|
||||
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
|
||||
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
|
||||
</div>`;
|
||||
html += withDebt.map(s => `
|
||||
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
|
||||
<div class="debt-head">
|
||||
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
|
||||
<div class="debt-chips">${debtChips(s.counts)}</div>
|
||||
</div>
|
||||
${s.pending.map(p => debtItemHtml(s, p)).join('')}
|
||||
</div>`).join('');
|
||||
const clear = summary.students_total - withDebt.length;
|
||||
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
|
||||
el.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function deleteDebtAssignment(id, scope, studentId) {
|
||||
let title = 'задание', studentName = '';
|
||||
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
|
||||
if (stu) {
|
||||
studentName = stu.name;
|
||||
const it = stu.pending.find(p => p.assignment_id === id);
|
||||
if (it) title = it.title;
|
||||
}
|
||||
const msg = scope === 'direct'
|
||||
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
|
||||
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
|
||||
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.deleteAssignment(id);
|
||||
LS.toast('Задание удалено', 'info');
|
||||
await loadDebts();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
/* ══ Detail tabs ══ */
|
||||
function switchDetailTab(btn) {
|
||||
const name = btn.dataset.tab;
|
||||
@@ -1208,6 +1333,7 @@
|
||||
document.getElementById('dtab-' + name).classList.add('active');
|
||||
if (name === 'announce') loadAnnouncements();
|
||||
if (name === 'dash') loadClassDashboard();
|
||||
if (name === 'debts') loadDebts();
|
||||
if (name === 'journal') loadJournal();
|
||||
if (name === 'settings') loadSettings();
|
||||
if (name === 'works') loadClassWorks();
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
LS.sidebar?.init();
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures();
|
||||
if (feats.collection === false) { window.location.replace('/403'); return; }
|
||||
if (feats.collection === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
await loadCollection();
|
||||
})();
|
||||
|
||||
+27
-16
@@ -1039,7 +1039,7 @@ body {
|
||||
body.no-class #lb-section { display: none !important; }
|
||||
|
||||
/* Gamification kill-switch.
|
||||
When admin turns off the feature, body.no-gamification is set by
|
||||
When admin turns off the feature, .no-gamification is set by
|
||||
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
|
||||
achievement / frame element must vanish — across the whole app,
|
||||
not just the dashboard. The rules below cover:
|
||||
@@ -1050,21 +1050,32 @@ body.no-class #lb-section { display: none !important; }
|
||||
• a catch-all [data-gamified] hook that wraps any future block —
|
||||
authors of new pages should wrap XP UI in a <div data-gamified>
|
||||
instead of inventing new classes. */
|
||||
body.no-gamification .gam-bar,
|
||||
body.no-gamification .lb-widget,
|
||||
body.no-gamification .achievements-section,
|
||||
body.no-gamification #tab-btn-achievements,
|
||||
body.no-gamification #tab-btn-shop,
|
||||
body.no-gamification #tab-achievements,
|
||||
body.no-gamification #tab-shop,
|
||||
body.no-gamification #frames-section,
|
||||
body.no-gamification .hero-xp-badge,
|
||||
body.no-gamification .po-xp,
|
||||
body.no-gamification .xp-card,
|
||||
body.no-gamification .xp-bar,
|
||||
body.no-gamification .xp-pill,
|
||||
body.no-gamification .xp-badge,
|
||||
body.no-gamification [data-gamified] { display: none !important; }
|
||||
.no-gamification .gam-bar,
|
||||
.no-gamification .lb-widget,
|
||||
.no-gamification .achievements-section,
|
||||
.no-gamification #tab-btn-achievements,
|
||||
.no-gamification #tab-btn-shop,
|
||||
.no-gamification #tab-achievements,
|
||||
.no-gamification #tab-shop,
|
||||
.no-gamification #frames-section,
|
||||
.no-gamification .hero-xp-badge,
|
||||
.no-gamification .po-xp,
|
||||
.no-gamification .xp-card,
|
||||
.no-gamification .xp-bar,
|
||||
.no-gamification .xp-pill,
|
||||
.no-gamification .xp-badge,
|
||||
/* challenges / еженедельные испытания (dashboard) */
|
||||
.no-gamification .ch-widget,
|
||||
.no-gamification #ch-section,
|
||||
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
|
||||
.no-gamification .streak-cal,
|
||||
.no-gamification #sr-streak,
|
||||
.no-gamification .hc-pet .chip-streak,
|
||||
.no-gamification .hc-pet .chip-goal,
|
||||
/* монеты (профиль) и xp-прогресс */
|
||||
.no-gamification #p-coins-row,
|
||||
.no-gamification .gam-progress,
|
||||
.no-gamification [data-gamified] { display: none !important; }
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
RESPONSIVE — SMALL PHONES (≤ 480px)
|
||||
|
||||
+148
-24
@@ -81,7 +81,33 @@
|
||||
}
|
||||
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
||||
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
||||
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||
|
||||
/* ── Live online-lesson banner ── */
|
||||
.live-lesson {
|
||||
display: flex; align-items: center; gap: 14px; text-decoration: none;
|
||||
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
|
||||
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
|
||||
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
|
||||
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
|
||||
@keyframes llPulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
|
||||
}
|
||||
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
|
||||
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
|
||||
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
|
||||
@media (max-width: 480px) {
|
||||
.live-lesson { padding: 12px 14px; gap: 10px; }
|
||||
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
|
||||
}
|
||||
.hero-card {
|
||||
position: relative; border-radius: 18px; padding: 18px 20px;
|
||||
display: flex; flex-direction: column; min-height: 196px;
|
||||
@@ -1532,6 +1558,13 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Live online-lesson status (student/teacher) -->
|
||||
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
|
||||
<span class="ll-dot"></span>
|
||||
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
|
||||
<span class="ll-cta" id="ll-cta">Присоединиться</span>
|
||||
</a>
|
||||
|
||||
<!-- Gamification Bar (students only) -->
|
||||
<div class="gam-bar" id="gam-bar" style="display:none">
|
||||
<div class="gam-level">
|
||||
@@ -1750,6 +1783,11 @@
|
||||
<div class="widget" id="w-tests">
|
||||
<div class="w-head"><div class="w-title">Тесты</div></div>
|
||||
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
|
||||
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
|
||||
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
|
||||
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
|
||||
<div class="subj-mini-grid" id="available-tests-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: Progress -->
|
||||
@@ -1884,6 +1922,7 @@
|
||||
<!-- Join modal -->
|
||||
<!-- Quick-start test modal -->
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script src="/js/sound.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
@@ -2222,10 +2261,14 @@
|
||||
async function loadSubjects() {
|
||||
const list = document.getElementById('subjects-list');
|
||||
try {
|
||||
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' };
|
||||
const subjects = await LS.getSubjects();
|
||||
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
|
||||
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
|
||||
const subjects = (await LS.getSubjects())
|
||||
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
|
||||
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
|
||||
list.innerHTML = subjects.map((s, si) => {
|
||||
const mode = s.default_mode || 'exam';
|
||||
let mode = s.default_mode || 'exam';
|
||||
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
|
||||
const count = s.default_count || 25;
|
||||
const testId = s.default_test_id || null;
|
||||
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
|
||||
@@ -2253,6 +2296,32 @@
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
|
||||
async function loadAvailableTests() {
|
||||
const wrap = document.getElementById('avail-tests-wrap');
|
||||
const list = document.getElementById('available-tests-list');
|
||||
if (!wrap || !list) return;
|
||||
try {
|
||||
const tests = await LS.getTests();
|
||||
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
|
||||
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||||
list.innerHTML = tests.map((t, i) => {
|
||||
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
|
||||
const iconName = ICONS[t.subject_slug] || 'book-open';
|
||||
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
|
||||
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
|
||||
<div class="smc-body">
|
||||
<div class="smc-name">${esc(t.title)}</div>
|
||||
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="smc-arrow"></i>
|
||||
</div>`;
|
||||
}).join('');
|
||||
wrap.style.display = '';
|
||||
reIcons();
|
||||
} catch { wrap.style.display = 'none'; }
|
||||
}
|
||||
|
||||
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
|
||||
async function loadAssignments() {
|
||||
try {
|
||||
@@ -2346,15 +2415,8 @@
|
||||
body.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
/* ── Urgency sort score (lower = shown first) ── */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4; // in progress <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> top
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
if (dlMs < 0) return -3; // overdue
|
||||
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
|
||||
if (dlMs < Infinity) return dlMs; // sorted by deadline
|
||||
return 1e12; // no deadline <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> last
|
||||
}
|
||||
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
/* ── Is assignment urgent for teacher (within 48h) ── */
|
||||
function isTeacherUrgent(a) {
|
||||
@@ -2422,7 +2484,7 @@
|
||||
}
|
||||
|
||||
/* ── Upload-only homework (no test, no file) ── */
|
||||
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) {
|
||||
if (AssignmentUtils.type(a) === 'upload') {
|
||||
const over = a.deadline && new Date(a.deadline) < new Date();
|
||||
const sub = _mySubmissions.get(a.id);
|
||||
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
||||
@@ -2659,18 +2721,22 @@
|
||||
reIcons(); return;
|
||||
}
|
||||
|
||||
// Classify
|
||||
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
|
||||
function classify(a) {
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const usedAtt = a.attempts_used ?? 0;
|
||||
if (a.textbook_id) {
|
||||
if (a.completed_at || a.textbook_all_read) return 'done';
|
||||
const t = AssignmentUtils.type(a);
|
||||
if (t === 'textbook') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
|
||||
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
|
||||
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
if (t === 'test') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
|
||||
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
|
||||
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
@@ -3670,12 +3736,36 @@
|
||||
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
|
||||
}
|
||||
|
||||
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
|
||||
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
|
||||
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
|
||||
function syncProgressCol() {
|
||||
const col = document.getElementById('w-progress-col');
|
||||
if (!col) return;
|
||||
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
|
||||
const e = document.getElementById(id);
|
||||
return e && getComputedStyle(e).display !== 'none';
|
||||
});
|
||||
col.style.display = any ? '' : 'none';
|
||||
}
|
||||
|
||||
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
|
||||
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
|
||||
function syncHeroRow() {
|
||||
const row = document.getElementById('hero-row');
|
||||
if (!row) return;
|
||||
const vis = [...row.querySelectorAll('.hero-card')]
|
||||
.filter(c => getComputedStyle(c).display !== 'none');
|
||||
row.style.display = vis.length ? '' : 'none';
|
||||
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
|
||||
function loadLastResultsWidget(rows) {
|
||||
const w = document.getElementById('w-last-results');
|
||||
if (!w) return;
|
||||
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
|
||||
if (!completed.length) { w.style.display = 'none'; return; }
|
||||
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
||||
w.style.display = '';
|
||||
document.getElementById('last-results-list').innerHTML = completed.map(h => {
|
||||
const pct = Math.round(h.score / h.total * 100);
|
||||
@@ -3689,6 +3779,7 @@
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
syncProgressCol();
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
|
||||
@@ -3702,7 +3793,7 @@
|
||||
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
|
||||
});
|
||||
const entries = Object.entries(bySubj);
|
||||
if (!entries.length) { w.style.display = 'none'; return; }
|
||||
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
||||
w.style.display = '';
|
||||
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
|
||||
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
|
||||
@@ -3713,6 +3804,7 @@
|
||||
<span class="sp-pct" style="color:${color}">${avg}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
syncProgressCol();
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
|
||||
@@ -4285,6 +4377,7 @@
|
||||
loadLabOfDay();
|
||||
loadPetHero();
|
||||
loadFlashcardWidget();
|
||||
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
||||
@@ -4298,6 +4391,7 @@
|
||||
renderFlashcardWidget(r);
|
||||
w.style.display = '';
|
||||
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
||||
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
|
||||
}
|
||||
|
||||
function renderFlashcardWidget(r) {
|
||||
@@ -4473,6 +4567,7 @@
|
||||
} else {
|
||||
// Student: full layout
|
||||
loadSubjects();
|
||||
loadAvailableTests();
|
||||
loadAssignments();
|
||||
loadStats();
|
||||
loadGamification();
|
||||
@@ -4481,13 +4576,42 @@
|
||||
loadDashboardStats();
|
||||
applyDashboardPrefs();
|
||||
}
|
||||
loadLiveLesson();
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
|
||||
LS.notif.init();
|
||||
|
||||
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
|
||||
async function loadLiveLesson() {
|
||||
const el = document.getElementById('live-lesson-banner');
|
||||
if (!el) return;
|
||||
let data;
|
||||
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
|
||||
const s = data && data.session;
|
||||
if (!s) { el.style.display = 'none'; return; }
|
||||
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
|
||||
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
|
||||
let sub;
|
||||
if (isTeacher) {
|
||||
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
|
||||
sub = online ? (online + ' онлайн') : 'ожидание учеников';
|
||||
} else {
|
||||
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
|
||||
}
|
||||
document.getElementById('ll-sub').textContent = sub;
|
||||
document.getElementById('ll-cta').textContent = isTeacher
|
||||
? 'Вернуться к доске'
|
||||
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
// Real-time SSE for page-specific events (notif handled by notifications.js)
|
||||
LS.connectSSE(ev => {
|
||||
if (ev.type === 'assignment') {
|
||||
LS.toast(ev.message, 'info');
|
||||
isTeacher ? loadAdminAssignments() : loadAssignments();
|
||||
} else if (ev.type === 'classroom_live') {
|
||||
loadLiveLesson();
|
||||
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
|
||||
} else if (ev.type === 'session') {
|
||||
LS.toast(ev.message, 'info');
|
||||
if (isTeacher) loadAdminSessions();
|
||||
|
||||
+228
-22
@@ -125,6 +125,41 @@
|
||||
/* student name in teacher view */
|
||||
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
|
||||
|
||||
/* ── section titles (multi-block student view) ── */
|
||||
.hw-sec-title {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
||||
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
|
||||
}
|
||||
.hw-sec-count {
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
|
||||
color: var(--violet); background: rgba(155,93,229,0.1);
|
||||
padding: 2px 9px; border-radius: 999px;
|
||||
}
|
||||
#hw-active-wrap { margin-bottom: 28px; }
|
||||
|
||||
/* ── active assignment cards ── */
|
||||
.hw-acard {
|
||||
background: #fff; border-radius: 16px; padding: 16px 18px;
|
||||
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
|
||||
display: flex; align-items: center; gap: 14px; transition: all .15s;
|
||||
}
|
||||
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
|
||||
.hw-acard.over { border-left-color: #EF476F; }
|
||||
.hw-acard.urgent { border-left-color: #F59E0B; }
|
||||
.hw-acard-icon {
|
||||
width: 42px; height: 42px; border-radius: 12px; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.hw-acard-body { flex: 1; min-width: 0; }
|
||||
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
|
||||
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
|
||||
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
|
||||
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
|
||||
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 16px 14px 80px; }
|
||||
.hw-top { gap: 8px; }
|
||||
@@ -133,6 +168,8 @@
|
||||
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
|
||||
.hw-card-actions { flex-wrap: wrap; }
|
||||
.hw-upload-area { padding: 20px 16px; }
|
||||
.hw-acard { flex-wrap: wrap; }
|
||||
.hw-acard-right { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 12px 10px 80px; }
|
||||
@@ -152,8 +189,15 @@
|
||||
<div class="page-title">Домашние задания</div>
|
||||
<div class="page-sub" id="hw-sub">Загрузка…</div>
|
||||
|
||||
<!-- Student: active assignments (что нужно сделать) -->
|
||||
<div id="hw-active-wrap" style="display:none">
|
||||
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
|
||||
<div class="hw-list" id="hw-active-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Student: upload area -->
|
||||
<div id="hw-upload-wrap" style="display:none">
|
||||
<div class="hw-sec-title">Сдать работу</div>
|
||||
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
|
||||
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
|
||||
<div class="hw-upload-text">Загрузить работу</div>
|
||||
@@ -195,6 +239,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Student: status filters -->
|
||||
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
|
||||
<div class="hw-top" id="hw-top-student" style="display:none">
|
||||
<div class="hw-status-filters">
|
||||
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
|
||||
@@ -213,6 +258,7 @@
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script>
|
||||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
@@ -247,6 +293,14 @@
|
||||
resubmitted: 'Повторно', accepted: 'Принято'
|
||||
};
|
||||
|
||||
/* subject label/colour/icon maps (как на дашборде) */
|
||||
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
||||
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
|
||||
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
|
||||
|
||||
let _assignments = []; // актуальные задания (LS.myAssignments)
|
||||
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
|
||||
|
||||
/* ── filter ── */
|
||||
function filterStatus(st, btn) {
|
||||
_statusFilter = st;
|
||||
@@ -257,33 +311,31 @@
|
||||
|
||||
/* ── STUDENT VIEW ── */
|
||||
async function initStudent() {
|
||||
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
|
||||
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
|
||||
document.getElementById('hw-top-student').style.display = '';
|
||||
document.getElementById('hw-mysubs-title').style.display = '';
|
||||
|
||||
// Find student's class
|
||||
// Find student's class (нужен для загрузки работ без привязки к заданию)
|
||||
try {
|
||||
const classes = await LS.myClasses();
|
||||
if (classes.length) {
|
||||
_studentClassId = classes[0].id;
|
||||
document.getElementById('hw-upload-wrap').style.display = '';
|
||||
|
||||
// Load assignments for selector
|
||||
try {
|
||||
const feed = await LS.classFeed(classes[0].id);
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
(feed.assignments || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.title;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Load submissions
|
||||
// Грузим актуальные задания (все классы) + сдачи параллельно
|
||||
try {
|
||||
_submissions = await LS.getMySubmissions();
|
||||
const [assigns, subs] = await Promise.all([
|
||||
LS.myAssignments().catch(() => []),
|
||||
LS.getMySubmissions().catch(() => []),
|
||||
]);
|
||||
_assignments = Array.isArray(assigns) ? assigns : [];
|
||||
_submissions = Array.isArray(subs) ? subs : [];
|
||||
_subByAsgn.clear();
|
||||
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
|
||||
populateAssignmentSelect(_assignments);
|
||||
renderActiveAssignments();
|
||||
renderSubmissions();
|
||||
} catch {
|
||||
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
|
||||
@@ -312,14 +364,22 @@
|
||||
}
|
||||
|
||||
async function submitHomework() {
|
||||
if (!_selectedFile || !_studentClassId) return;
|
||||
if (!_selectedFile) return;
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
const assignId = sel.value;
|
||||
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
|
||||
// иначе — первый класс ученика.
|
||||
let classId = _studentClassId;
|
||||
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
|
||||
classId = sel.selectedOptions[0].dataset.class;
|
||||
}
|
||||
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
|
||||
const btn = document.getElementById('hw-submit-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', _selectedFile);
|
||||
fd.append('class_id', _studentClassId);
|
||||
const assignId = document.getElementById('hw-assignment-sel').value;
|
||||
fd.append('class_id', classId);
|
||||
if (assignId) fd.append('assignment_id', assignId);
|
||||
const msg = document.getElementById('hw-message').value.trim();
|
||||
if (msg) fd.append('message', msg);
|
||||
@@ -336,12 +396,20 @@
|
||||
|
||||
// Reload
|
||||
_submissions = await LS.getMySubmissions();
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
} catch (e) {
|
||||
LS.toast(e.message || 'Ошибка отправки', 'error');
|
||||
} finally { btn.disabled = !_selectedFile; }
|
||||
}
|
||||
|
||||
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
|
||||
function syncStudentLists() {
|
||||
_subByAsgn.clear();
|
||||
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
|
||||
renderActiveAssignments();
|
||||
renderSubmissions();
|
||||
}
|
||||
|
||||
async function resubmitHomework(subId) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
@@ -355,7 +423,7 @@
|
||||
await LS.resubmitWork(subId, fd);
|
||||
LS.toast('Работа отправлена повторно!', 'success');
|
||||
_submissions = await LS.getMySubmissions();
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
};
|
||||
input.click();
|
||||
@@ -366,7 +434,7 @@
|
||||
try {
|
||||
await LS.deleteSubmission(id);
|
||||
_submissions = _submissions.filter(s => s.id !== id);
|
||||
renderSubmissions();
|
||||
syncStudentLists();
|
||||
LS.toast('Удалено', 'info');
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
@@ -381,6 +449,144 @@
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
|
||||
|
||||
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
|
||||
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
|
||||
function asgnType(a) { return AssignmentUtils.type(a); }
|
||||
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
function deadlineChip(a) {
|
||||
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
|
||||
const dlMs = new Date(a.deadline) - Date.now();
|
||||
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||||
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
|
||||
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
|
||||
const days = Math.ceil(dlMs / 86400000);
|
||||
const txt = days === 1 ? '1 день' : `${days} дн.`;
|
||||
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
|
||||
}
|
||||
|
||||
function actionFor(a) {
|
||||
const t = asgnType(a);
|
||||
if (t === 'textbook') {
|
||||
let hash = '';
|
||||
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
|
||||
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
|
||||
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
|
||||
}
|
||||
if (t === 'file') {
|
||||
const sub = _subByAsgn.get(a.id);
|
||||
const submit = sub && sub.status !== 'revision'
|
||||
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
|
||||
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
|
||||
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
|
||||
}
|
||||
if (t === 'upload') {
|
||||
const sub = _subByAsgn.get(a.id);
|
||||
if (sub && sub.status !== 'revision') {
|
||||
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
|
||||
}
|
||||
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
|
||||
}
|
||||
// test
|
||||
const inProgress = a.session_status === 'in_progress';
|
||||
const isDone = a.session_status === 'completed';
|
||||
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
|
||||
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
|
||||
}
|
||||
|
||||
function activeCardHtml(a) {
|
||||
const t = asgnType(a);
|
||||
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
|
||||
const icon = t === 'textbook' ? 'book-open-text'
|
||||
: t === 'file' ? 'paperclip'
|
||||
: t === 'upload' ? 'upload'
|
||||
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
const over = dlMs < 0;
|
||||
const urgent = !over && dlMs < 24 * 3600 * 1000;
|
||||
const cls = over ? ' over' : urgent ? ' urgent' : '';
|
||||
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
|
||||
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
|
||||
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
|
||||
return `<div class="hw-acard${cls}" style="--ac:${color}">
|
||||
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
|
||||
<div class="hw-acard-body">
|
||||
<div class="hw-acard-title">${esc(a.title)}</div>
|
||||
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
|
||||
</div>
|
||||
<div class="hw-acard-right">${actionFor(a)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderActiveAssignments() {
|
||||
const wrap = document.getElementById('hw-active-wrap');
|
||||
const list = document.getElementById('hw-active-list');
|
||||
if (!wrap || !list) return;
|
||||
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
|
||||
// обычные тесты/экзамены сюда не попадают.
|
||||
const active = _assignments
|
||||
.filter(a => a.is_homework && !asgnDone(a))
|
||||
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
|
||||
if (!active.length) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = '';
|
||||
document.getElementById('hw-active-count').textContent = active.length;
|
||||
list.innerHTML = active.map(activeCardHtml).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
|
||||
function populateAssignmentSelect(list) {
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
|
||||
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
|
||||
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
|
||||
opt.dataset.asgn = '1';
|
||||
if (a.class_id) opt.dataset.class = a.class_id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Начать/продолжить тест-задание (как на дашборде).
|
||||
async function startAsgn(e, id, mode) {
|
||||
const btn = e.currentTarget;
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await LS.startAssignment(id);
|
||||
if (r.error && r.max_attempts) {
|
||||
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
|
||||
btn.disabled = false; btn.textContent = orig; return;
|
||||
}
|
||||
const aMode = r.assignment_mode || mode || 'exam';
|
||||
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
|
||||
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
|
||||
} catch (err) {
|
||||
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
|
||||
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
|
||||
btn.disabled = false; btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
|
||||
function sdatNow(assignId) {
|
||||
const wrap = document.getElementById('hw-upload-wrap');
|
||||
if (!wrap || wrap.style.display === 'none') {
|
||||
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
|
||||
}
|
||||
const sel = document.getElementById('hw-assignment-sel');
|
||||
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const area = document.getElementById('hw-upload-area');
|
||||
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
|
||||
}
|
||||
|
||||
/* ── TEACHER VIEW ── */
|
||||
async function initTeacher() {
|
||||
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
|
||||
|
||||
@@ -284,9 +284,15 @@
|
||||
el.innerHTML = rows.map(r => {
|
||||
const dt = new Date(r.created_at);
|
||||
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
||||
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
|
||||
const isClient = r.level === 'client';
|
||||
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
|
||||
const badge = isClient
|
||||
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
|
||||
: '';
|
||||
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||||
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
|
||||
${badge}
|
||||
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
|
||||
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
}
|
||||
|
||||
async function bulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
|
||||
if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
const classes = _targets.classes || [];
|
||||
try {
|
||||
await Promise.all(classes.map(c =>
|
||||
@@ -436,7 +436,7 @@
|
||||
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
|
||||
const srcId = Number(sel.value);
|
||||
const srcName = sel.options[sel.selectedIndex].text;
|
||||
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
|
||||
if (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) return;
|
||||
try {
|
||||
const src = await LS.accessClassOpen(srcId);
|
||||
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
|
||||
@@ -449,7 +449,7 @@
|
||||
}
|
||||
|
||||
async function classBulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||||
if (!allow && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||||
try {
|
||||
await Promise.all(all.map(([type, ref]) =>
|
||||
@@ -543,7 +543,7 @@
|
||||
const classes = _matrix.classes || [];
|
||||
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
|
||||
const open = !allOpen;
|
||||
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
|
||||
if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
try {
|
||||
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
|
||||
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
|
||||
@@ -557,7 +557,7 @@
|
||||
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
|
||||
const open = !allOpen;
|
||||
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
|
||||
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
|
||||
if (!open && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
|
||||
try {
|
||||
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
|
||||
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
|
||||
|
||||
@@ -65,10 +65,11 @@
|
||||
|
||||
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
||||
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
|
||||
var health = cfg.health || {};
|
||||
|
||||
// ── Баннер 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 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';
|
||||
@@ -108,6 +109,20 @@
|
||||
'</div>';
|
||||
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 sc = document.createElement('div');
|
||||
@@ -117,7 +132,9 @@
|
||||
'<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-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">Оценки (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);
|
||||
@@ -143,11 +160,13 @@
|
||||
var lim = L
|
||||
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</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' : '') + '">' +
|
||||
'<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>' : '') +
|
||||
'<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-pca">' +
|
||||
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
|
||||
@@ -249,11 +268,89 @@
|
||||
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-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 () {
|
||||
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'); }
|
||||
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
||||
});
|
||||
|
||||
// ── Сканер моделей ──
|
||||
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 || {};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let inited = false;
|
||||
|
||||
const GAME_FEATURES = [
|
||||
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
|
||||
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
||||
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
||||
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
||||
@@ -13,12 +14,16 @@
|
||||
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
||||
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
|
||||
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
|
||||
{ key: 'sitemap', label: 'Путеводитель', desc: 'Пункт «Путеводитель» в меню — обзорная карта разделов системы', icon: 'map' },
|
||||
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
|
||||
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
|
||||
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
|
||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
||||
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
||||
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
|
||||
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
|
||||
];
|
||||
|
||||
const FS_FEATURES = [
|
||||
|
||||
@@ -452,6 +452,24 @@
|
||||
<i data-lucide="file-text"></i> Audit log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
|
||||
<div class="ov-card danger" style="padding-bottom:16px">
|
||||
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
|
||||
Сброс системы «чистый запуск»
|
||||
</div>
|
||||
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
|
||||
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
|
||||
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются — авторский контент
|
||||
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
|
||||
Действие необратимо.
|
||||
</div>
|
||||
<button class="ov-quick-btn" id="ov-reset-system-btn"
|
||||
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
|
||||
<i data-lucide="trash-2"></i> Сбросить систему…
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/* ── wire quick-links via event delegation ───────────────── */
|
||||
@@ -459,9 +477,119 @@
|
||||
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
|
||||
});
|
||||
|
||||
const resetBtn = el.querySelector('#ov-reset-system-btn');
|
||||
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
|
||||
async function openResetModal() {
|
||||
const e = LS.esc;
|
||||
const m = LS.modal({
|
||||
title: 'Сброс системы — чистый запуск',
|
||||
size: 'md',
|
||||
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
|
||||
actions: [{ label: 'Отмена' }],
|
||||
});
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await LS.api('/api/admin/reset-system/plan');
|
||||
} catch (err) {
|
||||
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const kept = plan.keptAdmin || {};
|
||||
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
|
||||
const wipeRows = plan.wipeRows || 0;
|
||||
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
|
||||
return a + (typeof r.rows === 'number' ? r.rows : 0);
|
||||
}, 0);
|
||||
const unknownNote = (plan.unknown && plan.unknown.length)
|
||||
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
|
||||
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
|
||||
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
|
||||
: '';
|
||||
|
||||
m.setBody(
|
||||
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
|
||||
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
|
||||
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
|
||||
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
|
||||
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
|
||||
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
|
||||
' <span style="color:#56687A">(вы)</span></div>' +
|
||||
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
|
||||
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
|
||||
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
|
||||
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
|
||||
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
|
||||
'</ul>' +
|
||||
unknownNote +
|
||||
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
|
||||
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
|
||||
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
|
||||
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
const inp = m.body.querySelector('#ov-reset-confirm-inp');
|
||||
function syncBtn() {
|
||||
const ok = inp && inp.value.trim() === 'СБРОС';
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (btn) btn.disabled = !ok;
|
||||
}
|
||||
|
||||
function setReadyActions() {
|
||||
m.setActions([
|
||||
{ label: 'Отмена' },
|
||||
{
|
||||
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
|
||||
onClick: doReset,
|
||||
},
|
||||
]);
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (btn) btn.disabled = true;
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
const btn = document.getElementById('ov-reset-go');
|
||||
if (!inp || inp.value.trim() !== 'СБРОС') return;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
|
||||
m.setError('');
|
||||
let res;
|
||||
try {
|
||||
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
|
||||
} catch (err) {
|
||||
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
|
||||
return;
|
||||
}
|
||||
m.setBody(
|
||||
'<div style="text-align:center;padding:14px 0">' +
|
||||
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
|
||||
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
|
||||
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
|
||||
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
|
||||
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
|
||||
(res.remainingUsers || 1) + '</strong>.<br>' +
|
||||
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
|
||||
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
|
||||
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
|
||||
}
|
||||
|
||||
setReadyActions();
|
||||
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById('overview-content');
|
||||
if (!el) return;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
|
||||
// Старт сессии поддерживает только exam/practice (topic/random убраны — давали 400 на дашборде).
|
||||
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест' };
|
||||
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||||
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
||||
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
|
||||
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
|
||||
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
|
||||
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
|
||||
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||
</div>
|
||||
@@ -77,16 +79,15 @@
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const [t, subjectQs] = await Promise.all([
|
||||
LS.getTest(id),
|
||||
LS.getQuestions(
|
||||
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
|
||||
null, 'date_asc'
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
const t = await LS.getTest(id);
|
||||
const inIds = new Set(t.questions.map(q => q.id));
|
||||
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
|
||||
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
|
||||
const prev = _tstPickerCache[id] || {};
|
||||
_tstPickerCache[id] = {
|
||||
subject_slug: t.subject_slug, inIds,
|
||||
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
|
||||
rows: [], total: 0, page: 1, loading: false,
|
||||
};
|
||||
|
||||
inner.innerHTML = `
|
||||
<div class="tst-cols">
|
||||
@@ -96,17 +97,89 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="tst-panel-title">Добавить вопросы</div>
|
||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
|
||||
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
|
||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
|
||||
<div class="tst-pick-filters">
|
||||
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
|
||||
<option value="">Любая сложность</option>
|
||||
<option value="1">Лёгкий</option>
|
||||
<option value="2">Средний</option>
|
||||
<option value="3">Сложный</option>
|
||||
</select>
|
||||
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
|
||||
<option value="">Любой тип</option>
|
||||
<option value="single">Один</option>
|
||||
<option value="multi">Несколько</option>
|
||||
<option value="true_false">Верно/Нет</option>
|
||||
<option value="short_answer">Краткий</option>
|
||||
<option value="matching">Сопоставление</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
|
||||
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
// restore search/filters into controls
|
||||
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
|
||||
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
|
||||
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
|
||||
AdminCtx.renderMath(inner);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
await pickerLoad(id, true);
|
||||
} catch (e) {
|
||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
|
||||
reset=true — новый поиск/фильтр (страница 1, заменяем); иначе — «показать ещё». */
|
||||
async function pickerLoad(id, reset) {
|
||||
const cache = _tstPickerCache[id];
|
||||
if (!cache || cache.loading) return;
|
||||
cache.loading = true;
|
||||
if (reset) { cache.page = 1; cache.rows = []; }
|
||||
const listEl = document.getElementById('tstpicker-' + id);
|
||||
const footEl = document.getElementById('tstfoot-' + id);
|
||||
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
p.set('subject', cache.subject_slug || '');
|
||||
p.set('sort', 'date_desc');
|
||||
p.set('page', cache.page);
|
||||
p.set('limit', 100);
|
||||
if (cache.q) p.set('q', cache.q);
|
||||
if (cache.difficulty) p.set('difficulty', cache.difficulty);
|
||||
if (cache.type) p.set('type', cache.type);
|
||||
const data = await LS.get('/api/questions?' + p.toString());
|
||||
const rows = Array.isArray(data) ? data : (data.rows || []);
|
||||
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
|
||||
cache.rows = reset ? rows : cache.rows.concat(rows);
|
||||
cache.page += 1;
|
||||
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
|
||||
if (footEl) footEl.innerHTML = pickerFootHtml(id);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (e) {
|
||||
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
|
||||
} finally { cache.loading = false; }
|
||||
}
|
||||
|
||||
function pickerFootHtml(id) {
|
||||
const c = _tstPickerCache[id];
|
||||
if (!c || !c.total) return '';
|
||||
const more = c.rows.length < c.total
|
||||
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
|
||||
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
|
||||
}
|
||||
|
||||
function pickerMore(id) { pickerLoad(id, false); }
|
||||
|
||||
function pickerFilterChange(id) {
|
||||
const c = _tstPickerCache[id];
|
||||
if (!c) return;
|
||||
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
|
||||
c.type = document.getElementById('tstft-' + id)?.value || '';
|
||||
pickerLoad(id, true);
|
||||
}
|
||||
|
||||
function renderTstQList(questions, tid) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
|
||||
@@ -127,7 +200,11 @@
|
||||
|
||||
function renderTstPicker(questions, inIds, tid) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||||
if (!questions.length) {
|
||||
const c = _tstPickerCache[tid] || {};
|
||||
const searching = c.q || c.difficulty || c.type;
|
||||
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</div>`;
|
||||
}
|
||||
return questions.map(q => {
|
||||
const added = inIds.has(q.id);
|
||||
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
||||
@@ -145,15 +222,13 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function filterTstPicker(tid) {
|
||||
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
|
||||
const cache = _tstPickerCache[tid];
|
||||
const _pickDebounce = {};
|
||||
function filterTstPicker(tid) {
|
||||
const cache = _tstPickerCache[tid];
|
||||
if (!cache) return;
|
||||
const filtered = search
|
||||
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
|
||||
: cache.subjectQs;
|
||||
const picker = document.getElementById('tstpicker-'+tid);
|
||||
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
|
||||
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
|
||||
clearTimeout(_pickDebounce[tid]);
|
||||
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
|
||||
}
|
||||
|
||||
async function tstAddQ(tid, qid) {
|
||||
@@ -261,11 +336,27 @@
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
|
||||
async function toggleTstAvail(id) {
|
||||
const t = allTests.find(x => x.id === id);
|
||||
if (!t) return;
|
||||
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
|
||||
const next = t.available_to_students ? 0 : 1;
|
||||
try {
|
||||
await LS.updateTest(id, { available_to_students: next });
|
||||
t.available_to_students = next;
|
||||
renderTests();
|
||||
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose handlers
|
||||
window.loadTests = load;
|
||||
window.renderTests = renderTests;
|
||||
window.toggleTstDrawer = toggleTstDrawer;
|
||||
window.filterTstPicker = filterTstPicker;
|
||||
window.pickerMore = pickerMore;
|
||||
window.pickerFilterChange = pickerFilterChange;
|
||||
window.tstAddQ = tstAddQ;
|
||||
window.tstRemoveQ = tstRemoveQ;
|
||||
window.setTstShowAnswers = setTstShowAnswers;
|
||||
@@ -274,6 +365,7 @@
|
||||
window.closeTstModal = closeTstModal;
|
||||
window.saveTst = saveTst;
|
||||
window.deleteTst = deleteTst;
|
||||
window.toggleTstAvail = toggleTstAvail;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.tests = {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
/* assignment-utils.js — единый источник правды для классификации заданий и статуса «сдано».
|
||||
*
|
||||
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
|
||||
* assignmentController.js) и начала расходиться. Теперь — один модуль, который грузится
|
||||
* и в браузере (window.AssignmentUtils), и в Node (module.exports) — как svg-sanitize.js.
|
||||
*
|
||||
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
|
||||
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
|
||||
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
|
||||
*/
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (typeof window !== 'undefined') window.AssignmentUtils = api;
|
||||
})(this, function () {
|
||||
|
||||
/* Тип задания: textbook | file | upload | test.
|
||||
Порядок проверки: учебник → файл → загрузка работы → тест. */
|
||||
function type(a) {
|
||||
if (a.textbook_id) return 'textbook';
|
||||
if (a.file_id) return 'file';
|
||||
if (a.is_homework && (a.count == null || a.count <= 1)
|
||||
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
/* «Закрыто» (сдано/выполнено/прочитано) — задание уходит из активных/долгов.
|
||||
sub — последняя сдача (объект с .status) для upload/file, иначе null/undefined.
|
||||
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
|
||||
status==='accepted' (пока не приняли — у ученика «висит»).
|
||||
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
|
||||
(ученик свою часть сделал — это уже не его долг). */
|
||||
function isDone(a, sub, opts) {
|
||||
opts = opts || {};
|
||||
const t = type(a);
|
||||
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
|
||||
if (t === 'upload' || t === 'file') {
|
||||
const st = sub && sub.status;
|
||||
if (!st || st === 'revision') return false;
|
||||
return opts.acceptedOnly ? st === 'accepted' : true;
|
||||
}
|
||||
// test
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const used = (a.attempts_used != null) ? a.attempts_used : 0;
|
||||
if (maxAtt > 0 && used >= maxAtt) return true;
|
||||
return a.session_status === 'completed' && a.mode !== 'repeat';
|
||||
}
|
||||
|
||||
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4;
|
||||
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
|
||||
if (dlMs < 0) return -3;
|
||||
if (dlMs < 24 * 3600 * 1000) return -2;
|
||||
if (dlMs < Infinity) return dlMs;
|
||||
return 1e12;
|
||||
}
|
||||
|
||||
return { type, isDone, urgencyScore };
|
||||
});
|
||||
+185
-11
@@ -282,6 +282,9 @@
|
||||
'.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;}',
|
||||
'@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-name-face{display:inline-block;transition:transform .2s;}',
|
||||
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:380px;max-width:92vw;background:#fff;border-radius:18px;',
|
||||
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);',
|
||||
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
|
||||
@@ -322,6 +325,10 @@
|
||||
'.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{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-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}',
|
||||
'.asst-chat:empty{display:none;}',
|
||||
@@ -359,6 +366,8 @@
|
||||
|
||||
/* ── рендер ──────────────────────────────────────────────────────────── */
|
||||
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) {
|
||||
opts = opts || {};
|
||||
@@ -476,12 +485,35 @@
|
||||
var h = sec.querySelector('.sec-h');
|
||||
var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
|
||||
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) {}
|
||||
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 = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
||||
@@ -501,21 +533,25 @@
|
||||
}
|
||||
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 MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' };
|
||||
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' };
|
||||
function openAsk(prefill) {
|
||||
var sel = _lastSel, pc = getPageContext();
|
||||
var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф';
|
||||
var noun2 = pc && pc.kind === 'lesson' ? 'урока' : 'параграфа';
|
||||
var ctxBtns = '';
|
||||
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>' +
|
||||
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' +
|
||||
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" 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">Конспект ' + noun2 + '</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 chips = '<div class="asst-chips">' + ctxBtns +
|
||||
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
|
||||
var isTch = (_role === 'teacher' || _role === 'admin');
|
||||
var modes = '<div class="asst-modes">' +
|
||||
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
|
||||
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
|
||||
'<button class="asst-mode" data-m="check">Проверить решение</button>' +
|
||||
(isTch ? '<button class="asst-mode" data-m="quiz">Тест в банк</button>' : '') +
|
||||
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
|
||||
openBubble(
|
||||
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
|
||||
@@ -530,7 +566,8 @@
|
||||
var mode = 'answer';
|
||||
renderChat(chatEl);
|
||||
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); });
|
||||
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
@@ -590,15 +627,15 @@
|
||||
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
|
||||
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
|
||||
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) {
|
||||
ph.remove();
|
||||
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 }); }
|
||||
else d.textContent = 'Не получилось нарисовать.';
|
||||
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 }); setNameFace('ecstatic'); }
|
||||
else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
|
||||
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}).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) || 'Не получилось нарисовать.';
|
||||
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
|
||||
});
|
||||
@@ -607,11 +644,90 @@
|
||||
q = (q || '').trim();
|
||||
if (q.length < 2) return;
|
||||
if (mode === 'draw') return drawInChat(q, chatEl);
|
||||
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(-6);
|
||||
_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.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
|
||||
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 });
|
||||
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(-6);
|
||||
_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.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
setNameFace('thinking');
|
||||
Promise.all([
|
||||
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
|
||||
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||
@@ -622,9 +738,10 @@
|
||||
if (r0.source === 'limit' || r0.source === 'error') {
|
||||
_chat.pop();
|
||||
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;
|
||||
setNameFace(model ? 'happy' : 'neutral');
|
||||
var ans = r0.answers || [];
|
||||
var sources = r0.sources || [];
|
||||
var found = (res[1] && res[1].results) || [];
|
||||
@@ -680,6 +797,63 @@
|
||||
}).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: онбординг-тур по разделам ───────────────────────────────────── */
|
||||
var TOUR = [
|
||||
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
|
||||
|
||||
@@ -197,7 +197,10 @@
|
||||
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
|
||||
title: s.user_name || '—',
|
||||
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
|
||||
act: 'Открыть', actHash: '/admin#sessions', solid: true,
|
||||
// Глубокая ссылка на ДЕТАЛИ конкретной сессии (открывается при любом статусе):
|
||||
// список /admin#sessions показывает только completed, поэтому зависшая (in_progress)
|
||||
// там не находилась. На странице деталей её можно посмотреть и удалить.
|
||||
act: 'Открыть', actHash: '/admin#sessions/' + s.id, solid: true,
|
||||
});
|
||||
});
|
||||
const ab = d.abandonedSessions24h || 0;
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
|
||||
/* Человекочитаемая метка варианта (ЦТ-2015 и т.п.); фолбэк — «Вариант N». */
|
||||
const labelOf = (n) => {
|
||||
const v = variants.find(x => x.n === n);
|
||||
return (v && v.label) || `Вариант ${n}`;
|
||||
};
|
||||
|
||||
/* ── Picker overlay ─────────────────────────────────────────── */
|
||||
function buildGrid() {
|
||||
pickerGrid.innerHTML = variants.map(v => {
|
||||
@@ -45,7 +51,7 @@
|
||||
const active = v.n === currentN ? ' active' : '';
|
||||
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.label || v.n}</button>`;
|
||||
}).join('');
|
||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
|
||||
@@ -74,7 +80,7 @@
|
||||
/* ── Variant rendering ──────────────────────────────────────── */
|
||||
async function selectVariant(n) {
|
||||
currentN = n;
|
||||
pickerLabel.textContent = `Вариант ${n}`;
|
||||
pickerLabel.textContent = labelOf(n);
|
||||
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
|
||||
|
||||
if (!tasksCache.has(n)) {
|
||||
@@ -94,7 +100,7 @@
|
||||
}
|
||||
|
||||
function renderVariant(n, tasks) {
|
||||
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
|
||||
main.innerHTML = `<div class="vp-title">${labelOf(n)}<small>${tasks.length} заданий</small></div>`;
|
||||
|
||||
const variantMeta = variants.find(v => v.n === n);
|
||||
const solvedTracked = new Set(); // tasks already solved this session
|
||||
|
||||
+416
-65
@@ -53,6 +53,8 @@ class TrigCircleSim {
|
||||
this.graphFn = 'sin';
|
||||
this.snapToNotable = true;
|
||||
this.animating = false;
|
||||
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
|
||||
this.showParity = false; // показать зеркальную точку −α (чётность/нечётность)
|
||||
|
||||
this._cx = 0; this._cy = 0; this._r = 0;
|
||||
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
|
||||
@@ -96,11 +98,14 @@ class TrigCircleSim {
|
||||
|
||||
this._drawBg(c);
|
||||
this._drawCircle(c);
|
||||
if (this.eq) this._drawEquation(c);
|
||||
if (this.showParity) this._drawParity(c);
|
||||
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
|
||||
this._drawParticles(c);
|
||||
if (window.LabFX) LabFX.particles.draw(c);
|
||||
|
||||
c.restore();
|
||||
this._ovClearUnused();
|
||||
this._fireUpdate();
|
||||
}
|
||||
|
||||
@@ -116,6 +121,103 @@ class TrigCircleSim {
|
||||
this._layout(); this.draw();
|
||||
}
|
||||
|
||||
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
|
||||
setEquation(fn, a, sols) {
|
||||
this.eq = { fn, a, sols: sols || [] };
|
||||
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
|
||||
this.draw();
|
||||
}
|
||||
clearEquation() { this.eq = null; this.draw(); }
|
||||
|
||||
_drawEquation(c) {
|
||||
const cx = this._cx, cy = this._cy, r = this._r;
|
||||
const { fn, a, sols } = this.eq;
|
||||
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
|
||||
c.save();
|
||||
/* направляющая линия значения */
|
||||
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
|
||||
c.beginPath();
|
||||
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
|
||||
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
|
||||
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
|
||||
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
|
||||
c.stroke(); c.setLineDash([]);
|
||||
/* точки-решения + подписи градусов */
|
||||
c.font = 'bold 11px Manrope,sans-serif';
|
||||
sols.forEach(ang => {
|
||||
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
|
||||
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
|
||||
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
|
||||
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
|
||||
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
|
||||
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
|
||||
});
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
|
||||
_drawParity(c) {
|
||||
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
|
||||
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
|
||||
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
|
||||
c.save();
|
||||
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
|
||||
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
|
||||
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
|
||||
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
|
||||
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
||||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
|
||||
_ov() {
|
||||
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
|
||||
return this._ovEl;
|
||||
}
|
||||
/* key — стабильный id подписи; latex — LaTeX (дробь/корень → KaTeX, иначе текст);
|
||||
x,y — CSS-px над canvas; anchor: c|l|r|t|b; boxed — тёмная плашка (для координат). */
|
||||
_ovLabel(key, latex, x, y, color, anchor, boxed) {
|
||||
const ov = this._ov(); if (!ov) return;
|
||||
this._ovMap = this._ovMap || {};
|
||||
this._ovUsed = this._ovUsed || {};
|
||||
let rec = this._ovMap[key];
|
||||
if (!rec) {
|
||||
const el = document.createElement('div');
|
||||
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
|
||||
el.style.willChange = 'transform';
|
||||
ov.appendChild(el);
|
||||
rec = this._ovMap[key] = { el, last: null, boxed: null };
|
||||
}
|
||||
if (rec.last !== latex) {
|
||||
// Любая 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) {
|
||||
this._animTarget = this._norm(rad);
|
||||
if (!this.animating) this._startAnim();
|
||||
@@ -130,7 +232,16 @@ class TrigCircleSim {
|
||||
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
|
||||
const deg = a * 180 / Math.PI;
|
||||
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
|
||||
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
|
||||
// Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
|
||||
let ref;
|
||||
if (a <= Math.PI / 2) ref = a;
|
||||
else if (a <= Math.PI) ref = Math.PI - a;
|
||||
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
|
||||
else ref = 2 * Math.PI - a;
|
||||
return {
|
||||
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
|
||||
refAngle: ref, refDeg: ref * 180 / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
/* ═══ Layout ═══════════════════════════════════════════════════════ */
|
||||
@@ -290,11 +401,10 @@ class TrigCircleSim {
|
||||
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
|
||||
c.strokeStyle = ag; c.lineWidth = 2.5;
|
||||
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
|
||||
/* label */
|
||||
const mid = a / 2, lr = ar + 18;
|
||||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
||||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
|
||||
/* label (KaTeX overlay: π-доля для табличных, иначе текст) */
|
||||
const mid = a / 2, lr = ar + 20;
|
||||
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
|
||||
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
|
||||
}
|
||||
|
||||
/* ── radius ── */
|
||||
@@ -388,9 +498,9 @@ class TrigCircleSim {
|
||||
|
||||
/* ── axis value badges ── */
|
||||
if (this.showSin && Math.abs(sinA) > 0.04)
|
||||
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
|
||||
this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
|
||||
if (this.showCos && Math.abs(cosA) > 0.04)
|
||||
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
|
||||
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
|
||||
|
||||
/* ── main point ── */
|
||||
const ps = this._hover || this._drag ? 10 : 8;
|
||||
@@ -406,8 +516,12 @@ class TrigCircleSim {
|
||||
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
|
||||
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
|
||||
|
||||
/* ── coordinate tooltip ── */
|
||||
this._tooltip(c, px, py, cosA, sinA);
|
||||
/* ── coordinate tooltip (KaTeX overlay) — выносим РАДИАЛЬНО НАРУЖУ за точку,
|
||||
чтобы не перекрывать центральную дугу угла и её подпись ── */
|
||||
const _odx = Math.cos(a), _ody = -Math.sin(a);
|
||||
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
|
||||
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
|
||||
'#fff', cosA >= 0 ? 'l' : 'r', true);
|
||||
|
||||
/* ── quadrant roman numeral ── */
|
||||
const qOff = r * 0.46;
|
||||
@@ -538,7 +652,6 @@ class TrigCircleSim {
|
||||
|
||||
const fn = this.graphFn;
|
||||
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 yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
|
||||
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();
|
||||
}
|
||||
|
||||
/* ±1 lines */
|
||||
if (fn==='sin'||fn==='cos') {
|
||||
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
|
||||
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
|
||||
c.setLineDash([]);
|
||||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
|
||||
c.textAlign='right'; c.textBaseline='middle';
|
||||
c.fillText('1', gx-5, sy(1)); c.fillText('−1', gx-5, sy(-1));
|
||||
}
|
||||
/* ── шкала значений по оси Y (значения на координатной плоскости) ── */
|
||||
const yVals = (fn==='tan'||fn==='cot')
|
||||
? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
|
||||
: [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
|
||||
yVals.forEach(([v, lx], i) => {
|
||||
const yy = sy(v);
|
||||
if (yy < gy + 6 || yy > gy + gh - 6) return;
|
||||
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 */
|
||||
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
|
||||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
|
||||
c.textAlign='center'; c.textBaseline='top';
|
||||
for (const [v,l] of ticks) {
|
||||
/* x ticks — линии на canvas, подписи KaTeX-оверлеем */
|
||||
const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
|
||||
[3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
|
||||
ticks.forEach(([v, lx], i) => {
|
||||
const xx = sx(v);
|
||||
if (xx < gx+6 || xx > gx+gw-6) continue;
|
||||
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
|
||||
c.setLineDash([3,3]);
|
||||
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
|
||||
c.setLineDash([]);
|
||||
c.fillText(l, xx, gy+gh+6);
|
||||
}
|
||||
if (xx < gx+6 || xx > gx+gw-6) return;
|
||||
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.setLineDash([3,3]);
|
||||
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); c.setLineDash([]);
|
||||
this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
|
||||
});
|
||||
|
||||
/* ── ghost curves (other functions, dimmed) ── */
|
||||
c.save();
|
||||
@@ -669,6 +783,21 @@ class TrigCircleSim {
|
||||
}
|
||||
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 ── */
|
||||
const curY = evFn(this.angle);
|
||||
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
|
||||
@@ -687,31 +816,21 @@ class TrigCircleSim {
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
|
||||
/* value badge */
|
||||
const txt = this._fmt(curY);
|
||||
c.font = 'bold 11px Manrope,sans-serif';
|
||||
const tm = c.measureText(txt);
|
||||
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
|
||||
c.fillStyle='rgba(12,12,22,0.85)';
|
||||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
|
||||
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
|
||||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
|
||||
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
|
||||
c.fillText(txt, bx2+7, by2);
|
||||
/* value badge (KaTeX overlay) */
|
||||
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
|
||||
/* подпись угла на оси X (развёртка: где текущий угол на графике) */
|
||||
this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
|
||||
mx, gy + 5, _TC.violet, 't', true);
|
||||
}
|
||||
|
||||
c.restore();
|
||||
|
||||
/* fn name badge */
|
||||
c.font='bold 13px Manrope,sans-serif';
|
||||
const tm2 = c.measureText(lbl);
|
||||
const bw3 = tm2.width+18, bh3 = 26;
|
||||
c.fillStyle='rgba(12,12,22,0.7)';
|
||||
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
|
||||
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);
|
||||
/* fn name badge (KaTeX-оверлей) */
|
||||
const _glblTex = fn === 'sin' ? 'y = \\sin x'
|
||||
: fn === 'cos' ? 'y = \\cos x'
|
||||
: fn === 'tan' ? 'y = \\operatorname{tg} x'
|
||||
: 'y = \\operatorname{ctg} x';
|
||||
this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
|
||||
}
|
||||
|
||||
/* ═══ Snap particles ═══════════════════════════════════════════════ */
|
||||
@@ -1029,6 +1148,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
}
|
||||
|
||||
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
|
||||
goToAngle нормализует — заодно демонстрирует котерминальность. */
|
||||
function trigSetAngleDeg(inp) {
|
||||
if (!trigSim || !inp) return;
|
||||
const v = parseFloat(String(inp.value || '').replace(',', '.'));
|
||||
if (!isFinite(v)) return;
|
||||
trigSim.goToAngle(v * Math.PI / 180);
|
||||
}
|
||||
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
|
||||
|
||||
/* Показать/скрыть график функций (тема «функции» — по умолчанию можно убрать,
|
||||
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
|
||||
function trigToggleGraph(rowEl) {
|
||||
if (!trigSim) return;
|
||||
const on = rowEl.classList.toggle('active');
|
||||
trigSim.toggleLayer('graph', on);
|
||||
const fns = document.getElementById('trig-graph-fns');
|
||||
if (fns) fns.style.display = on ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
|
||||
function _trigSolveAngles(fn, a) {
|
||||
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
|
||||
let raw;
|
||||
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
|
||||
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
|
||||
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
|
||||
const out = [];
|
||||
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
|
||||
return out.sort((p, q) => p - q);
|
||||
}
|
||||
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
|
||||
function _radLatex(rad) {
|
||||
const P = Math.PI;
|
||||
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
|
||||
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
|
||||
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
|
||||
for (const [v, l] of T) {
|
||||
if (Math.abs(rad - v) < 1e-6) return l;
|
||||
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* Общая формула решения (LaTeX) или {none:true}. */
|
||||
function _trigEqFormulaLatex(fn, a) {
|
||||
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
|
||||
if (fn === 'sin') {
|
||||
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
|
||||
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
if (fn === 'cos') {
|
||||
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
|
||||
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
|
||||
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
|
||||
}
|
||||
|
||||
var trigEqFn = 'sin';
|
||||
function trigSetEqFn(fn, btn) {
|
||||
trigEqFn = fn;
|
||||
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
function trigSolve() {
|
||||
if (!trigSim) return;
|
||||
const inp = document.getElementById('trig-eq-input');
|
||||
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
|
||||
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
|
||||
const fEl = document.getElementById('trig-eq-formula');
|
||||
const sEl = document.getElementById('trig-eq-sols');
|
||||
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
|
||||
const sols = _trigSolveAngles(trigEqFn, a);
|
||||
trigSim.setEquation(trigEqFn, a, sols);
|
||||
const K = window.katex;
|
||||
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
|
||||
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
|
||||
const f = _trigEqFormulaLatex(trigEqFn, a);
|
||||
if (fEl) {
|
||||
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
|
||||
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
|
||||
}
|
||||
if (sEl) sEl.textContent = sols.length
|
||||
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
|
||||
: '';
|
||||
}
|
||||
function trigClearEq() {
|
||||
if (!trigSim) return;
|
||||
trigSim.clearEquation();
|
||||
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
|
||||
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
|
||||
}
|
||||
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
|
||||
|
||||
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
|
||||
function _trigBuildValueTable() {
|
||||
const el = document.getElementById('trig-table');
|
||||
if (!el || el.dataset.built) return;
|
||||
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
|
||||
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
|
||||
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
|
||||
const body = [0, 30, 45, 60, 90].map(deg => {
|
||||
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
|
||||
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
|
||||
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
|
||||
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
|
||||
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
|
||||
}).join('');
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
|
||||
el.dataset.built = '1';
|
||||
}
|
||||
function trigToggleTable(rowEl) {
|
||||
const on = rowEl.classList.toggle('active');
|
||||
const el = document.getElementById('trig-table');
|
||||
if (!el) return;
|
||||
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
|
||||
else el.style.display = 'none';
|
||||
}
|
||||
|
||||
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
|
||||
function trigToggleParity(rowEl) {
|
||||
if (!trigSim) return;
|
||||
const on = rowEl.classList.toggle('active');
|
||||
trigSim.showParity = on;
|
||||
trigSim.draw();
|
||||
const pEl = document.getElementById('trig-parity');
|
||||
if (!pEl) return;
|
||||
pEl.style.display = on ? '' : 'none';
|
||||
if (on && !pEl.dataset.built) {
|
||||
pEl.innerHTML =
|
||||
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
|
||||
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
|
||||
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
|
||||
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
|
||||
pEl.dataset.built = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function _trigUpdateUI(s) {
|
||||
const _f = v => {
|
||||
if (v === undefined) return '—';
|
||||
@@ -1044,25 +1301,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
||||
};
|
||||
const degStr = s.deg.toFixed(1) + '°';
|
||||
|
||||
// Panel values (nice fractions)
|
||||
document.getElementById('trig-v-sin').textContent = _f(s.sin);
|
||||
document.getElementById('trig-v-cos').textContent = _f(s.cos);
|
||||
document.getElementById('trig-v-tan').textContent = _f(s.tan);
|
||||
document.getElementById('trig-v-cot').textContent = _f(s.cot);
|
||||
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
|
||||
const setMathVal = (id, v) => {
|
||||
const el = document.getElementById(id); if (!el) return;
|
||||
const lx = _latexVal(v);
|
||||
if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
|
||||
else el.textContent = lx;
|
||||
};
|
||||
setMathVal('trig-v-sin', s.sin);
|
||||
setMathVal('trig-v-cos', s.cos);
|
||||
setMathVal('trig-v-tan', s.tan);
|
||||
setMathVal('trig-v-cot', s.cot);
|
||||
|
||||
// Angle badge
|
||||
// Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
|
||||
const al = _angleLatex(s.angle);
|
||||
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
|
||||
document.getElementById('trig-angle-badge').innerHTML =
|
||||
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`;
|
||||
`<div>${_tex(head)}</div>` +
|
||||
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
|
||||
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
|
||||
|
||||
// Stats bar (nice fractions)
|
||||
// Опорный (острый) угол — guarded (панель может не иметь элемента)
|
||||
const refEl = document.getElementById('trig-ref');
|
||||
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
|
||||
// Знаки функций в текущей четверти
|
||||
const signsEl = document.getElementById('trig-signs');
|
||||
if (signsEl) {
|
||||
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '−' : '0');
|
||||
signsEl.innerHTML =
|
||||
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
|
||||
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
|
||||
}
|
||||
|
||||
// Точные значения + формула приведения (только для табличных углов)
|
||||
const fEl = document.getElementById('trig-formula');
|
||||
if (fEl) {
|
||||
const beta = Math.round(s.refDeg);
|
||||
const degR = Math.round(s.deg);
|
||||
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
|
||||
if (!isTable) {
|
||||
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
|
||||
} else {
|
||||
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
|
||||
const K = window.katex;
|
||||
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
|
||||
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
|
||||
let html = '';
|
||||
if (reduce) {
|
||||
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
|
||||
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
|
||||
: `360^\\circ - ${beta}^\\circ`;
|
||||
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
|
||||
}
|
||||
const line = (nm, color, val) => {
|
||||
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
|
||||
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
|
||||
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
|
||||
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
|
||||
};
|
||||
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
|
||||
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
|
||||
}
|
||||
}
|
||||
|
||||
// Подсветка строки таблицы значений (по опорному острому углу)
|
||||
const tbl = document.getElementById('trig-table');
|
||||
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
|
||||
const beta = Math.round(s.refDeg);
|
||||
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
|
||||
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Stats bar — значения тоже KaTeX (дроби/корни)
|
||||
document.getElementById('trigbar-angle').textContent = degStr;
|
||||
document.getElementById('trigbar-sin').textContent = _f(s.sin);
|
||||
document.getElementById('trigbar-cos').textContent = _f(s.cos);
|
||||
document.getElementById('trigbar-tan').textContent = _f(s.tan);
|
||||
document.getElementById('trigbar-cot').textContent = _f(s.cot);
|
||||
setMathVal('trigbar-sin', s.sin);
|
||||
setMathVal('trigbar-cos', s.cos);
|
||||
setMathVal('trigbar-tan', s.tan);
|
||||
setMathVal('trigbar-cot', s.cot);
|
||||
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
|
||||
}
|
||||
|
||||
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
|
||||
function _latexVal(v) {
|
||||
if (v === undefined) return '\\text{не опр.}';
|
||||
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
|
||||
if (a < 5e-4) return '0';
|
||||
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
|
||||
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
|
||||
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
|
||||
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
|
||||
if (Math.abs(a - 1) < 1e-3) return sg + '1';
|
||||
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
|
||||
return v.toFixed(3);
|
||||
}
|
||||
|
||||
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
|
||||
function _tex(latex) {
|
||||
const K = window.katex;
|
||||
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
|
||||
}
|
||||
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
|
||||
function _piLabelToLatex(l) {
|
||||
if (l === '0') return '0';
|
||||
const conv = s => s.replace('π', '\\pi');
|
||||
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
|
||||
return conv(l);
|
||||
}
|
||||
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
|
||||
function _angleLatex(rad) {
|
||||
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── KaTeX live preview ── */
|
||||
|
||||
/** Convert user ascii expression <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> LaTeX string for KaTeX preview */
|
||||
|
||||
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
|
||||
LS.notif.init();
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures();
|
||||
if (feats.knowledge_map === false) { window.location.replace('/403'); return; }
|
||||
if (feats.knowledge_map === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
|
||||
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
|
||||
|
||||
@@ -506,6 +506,16 @@
|
||||
<!-- left panel -->
|
||||
<div class="proj-panel" style="width:240px;gap:0">
|
||||
|
||||
<!-- Angle input -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Угол, °</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px">
|
||||
<input id="trig-angle-input" type="number" step="1" placeholder="напр. 150"
|
||||
onkeydown="trigAngleKey(event,this)"
|
||||
style="flex:1;min-width:0;padding:7px 10px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
|
||||
<button class="preset-btn" style="flex-shrink:0;padding:7px 12px" title="Перейти к углу"
|
||||
onclick="trigSetAngleDeg(document.getElementById('trig-angle-input'))"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- Function toggles -->
|
||||
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
||||
@@ -535,9 +545,14 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Graph function selector -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">График</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
|
||||
<!-- Graph (functions) — optional, can be hidden to focus on the circle -->
|
||||
<label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">График</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">функции</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-graph-fns" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
|
||||
<button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button>
|
||||
<button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button>
|
||||
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
|
||||
@@ -553,6 +568,55 @@
|
||||
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Reference (acute) angle + signs by quadrant -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Опорный угол · знаки</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.78rem">
|
||||
<span style="color:var(--text-3)">острый угол к оси</span>
|
||||
<span id="trig-ref" style="font-weight:800;color:var(--violet)">—</span>
|
||||
</div>
|
||||
<div id="trig-signs" style="text-align:center;font-size:0.72rem;color:var(--text-2)">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Exact values + reduction formula (table angles) -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
|
||||
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px">—</div>
|
||||
|
||||
<!-- Equation solver: fn(x) = a -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
|
||||
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
|
||||
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
|
||||
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
|
||||
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
|
||||
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
|
||||
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
|
||||
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
|
||||
</div>
|
||||
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
|
||||
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
|
||||
|
||||
<!-- Values table (first quadrant), toggle -->
|
||||
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleTable(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">Таблица значений</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">0–90°</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-table" style="display:none;margin-bottom:14px;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:6px 8px;overflow-x:auto"></div>
|
||||
|
||||
<!-- Parity (−α) + periods toggle -->
|
||||
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleParity(this)">
|
||||
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
|
||||
<span class="tri-layer-name">Чётность (−α)</span>
|
||||
<span class="tri-layer-hint" style="color:var(--text-3)">симметрия</span>
|
||||
<span class="tri-toggle"></span>
|
||||
</label>
|
||||
<div id="trig-parity" style="display:none;margin-bottom:14px;font-size:0.82rem;color:var(--text);line-height:1.7;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:8px 11px"></div>
|
||||
|
||||
<!-- Notable angles -->
|
||||
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
|
||||
@@ -562,8 +626,16 @@
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/4)">135°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/6)">150°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/6)">210°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/4)">225°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(4*Math.PI/3)">240°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/3)">300°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/4)">315°</button>
|
||||
<button class="preset-btn" onclick="trigGoTo(11*Math.PI/6)">330°</button>
|
||||
</div>
|
||||
|
||||
<!-- Angle info -->
|
||||
@@ -583,8 +655,10 @@
|
||||
</div><!-- /.proj-panel -->
|
||||
|
||||
<!-- canvas -->
|
||||
<div class="proj-canvas-outer">
|
||||
<div class="proj-canvas-outer" style="position:relative">
|
||||
<canvas id="trigcircle-canvas"></canvas>
|
||||
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
|
||||
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sim-body-wrap -->
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
.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-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-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); }
|
||||
@@ -110,6 +118,7 @@
|
||||
<option value="link">Ссылки</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mm-tagbar" id="mm-tags"></div>
|
||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +181,30 @@
|
||||
}
|
||||
let _mats = [];
|
||||
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 ── */
|
||||
function moveSelect(m) {
|
||||
@@ -202,6 +234,7 @@
|
||||
${chip}
|
||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||
<div class="mm-card-meta">${meta}</div>
|
||||
${tagsHtml(m)}
|
||||
<div class="mm-card-actions">
|
||||
${mv}
|
||||
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
||||
@@ -225,6 +258,7 @@
|
||||
${chip}
|
||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||
<div class="mm-card-meta">${meta}</div>
|
||||
${tagsHtml(m)}
|
||||
<div class="mm-card-actions">
|
||||
${mv}
|
||||
<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 !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) 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) {
|
||||
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||
if (!hay.includes(_filter.q)) return false;
|
||||
@@ -326,6 +361,7 @@
|
||||
grid.innerHTML = rows.length
|
||||
? rows.map(card).join('')
|
||||
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||
renderTags();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
@@ -345,7 +381,8 @@
|
||||
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
||||
function onKind(v) { _filter.kind = v; 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 ── */
|
||||
async function moveMaterial(id, cid) {
|
||||
@@ -366,6 +403,7 @@
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
||||
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
|
||||
</div>`;
|
||||
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: () => m.close() },
|
||||
@@ -374,7 +412,8 @@
|
||||
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||
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'); }
|
||||
} },
|
||||
] });
|
||||
@@ -388,12 +427,14 @@
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<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>` : ''}
|
||||
<input id="mm-ed-tags" value="${esc(tagsOf(mt).join(', '))}" placeholder="Теги через запятую (напр. алгебра, формулы)" style="${FLD}" />
|
||||
</div>`;
|
||||
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: () => m.close() },
|
||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
||||
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(); }
|
||||
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() {
|
||||
lucide.createIcons();
|
||||
const feats = await LS.loadFeatures().catch(() => ({}));
|
||||
if (feats.red_book === false) { window.location.replace('/403'); return; }
|
||||
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
|
||||
LS.hideDisabledFeatures?.();
|
||||
|
||||
// Auth (sidebar)
|
||||
|
||||
@@ -196,7 +196,8 @@
|
||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||
|
||||
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
||||
if (LS.loadFeatures) {
|
||||
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
|
||||
if (LS.loadFeatures && !ip.isAdmin) {
|
||||
LS.loadFeatures().then(function (feats) {
|
||||
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
|
||||
}).catch(function () {});
|
||||
|
||||
@@ -479,6 +479,15 @@
|
||||
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
|
||||
function openLabSim(simId, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
|
||||
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
|
||||
try {
|
||||
const u = LS.getUser && LS.getUser();
|
||||
if (!(u && u.role === 'admin')) {
|
||||
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
|
||||
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
|
||||
}
|
||||
} catch (e) { /* нет кэша — открываем как раньше */ }
|
||||
location.href = '/lab?sim=' + encodeURIComponent(simId);
|
||||
}
|
||||
window.openLabSim = openLabSim;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Пожелания — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; }
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 26px 32px 100px; }
|
||||
|
||||
/* ── hero ── */
|
||||
.wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
|
||||
.wq-hero-txt { flex: 1; min-width: 200px; }
|
||||
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.18rem; font-weight: 800; color: #0F172A; margin-bottom: 4px; }
|
||||
.page-sub { font-size: 0.82rem; color: var(--text-3); line-height: 1.5; }
|
||||
.wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
|
||||
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
|
||||
.wq-new-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(155,93,229,0.34); }
|
||||
.wq-new-btn.open { background: #fff; color: var(--text-2); border: 1.5px solid rgba(15,23,42,0.12); box-shadow: none; }
|
||||
|
||||
/* ── submit form (collapsible) ── */
|
||||
.wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
|
||||
overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
|
||||
.wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
|
||||
.wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
|
||||
.wq-cat-pick { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.wq-cat-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; cursor: pointer;
|
||||
border: 1.5px solid rgba(15,23,42,0.1); background: #fff; font-size: 0.78rem; font-weight: 600; color: var(--text-2); transition: all .15s; }
|
||||
.wq-cat-opt:hover { border-color: var(--cc); }
|
||||
.wq-cat-opt.sel { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
|
||||
.wq-cat-opt i { width: 14px; height: 14px; }
|
||||
.wq-inp, .wq-area { width: 100%; padding: 11px 13px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #0F172A; outline: none; transition: border-color .15s; }
|
||||
.wq-inp:focus, .wq-area:focus { border-color: var(--violet); }
|
||||
.wq-area { min-height: 74px; resize: vertical; margin-top: 10px; }
|
||||
.wq-form-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 10px; }
|
||||
.wq-counter { font-size: 0.72rem; color: var(--text-3); }
|
||||
|
||||
/* ── stat / status filter pills ── */
|
||||
.wq-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.wq-stat { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 13px; cursor: pointer;
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07); transition: all .15s; }
|
||||
.wq-stat:hover { border-color: var(--sc, #9B5DE5); }
|
||||
.wq-stat.active { border-color: var(--sc, #9B5DE5); background: color-mix(in srgb, var(--sc, #9B5DE5) 9%, #fff); }
|
||||
.wq-stat-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--sc, #9B5DE5); flex-shrink: 0; }
|
||||
.wq-stat-lbl { font-size: 0.78rem; font-weight: 600; color: var(--text-2); }
|
||||
.wq-stat-num { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #0F172A; }
|
||||
|
||||
/* ── sub-bar: category filter + search ── */
|
||||
.wq-subbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.wq-cats { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wq-cchip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
|
||||
border: 1.5px solid rgba(15,23,42,0.1); background: transparent; font-size: 0.73rem; font-weight: 600; color: var(--text-3); transition: all .15s; }
|
||||
.wq-cchip:hover { border-color: var(--cc); color: var(--cc); }
|
||||
.wq-cchip.active { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
|
||||
.wq-cchip i { width: 12px; height: 12px; }
|
||||
.wq-search { margin-left: auto; min-width: 180px; flex: 1; max-width: 280px; padding: 8px 13px; border: 1.5px solid rgba(15,23,42,0.1);
|
||||
border-radius: 11px; font-family: 'Manrope', sans-serif; font-size: 0.82rem; outline: none; transition: border-color .15s; }
|
||||
.wq-search:focus { border-color: var(--violet); }
|
||||
|
||||
/* ── wish cards ── */
|
||||
.w-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
|
||||
display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
|
||||
.w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
|
||||
@keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.w-cat-ic { width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: color-mix(in srgb, var(--cc) 13%, #fff); color: var(--cc); }
|
||||
.w-cat-ic i { width: 19px; height: 19px; }
|
||||
.w-main { flex: 1; min-width: 0; }
|
||||
.w-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 3px; }
|
||||
.w-title { font-size: 0.93rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
|
||||
.w-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
.w-badge i { width: 11px; height: 11px; }
|
||||
.wb-new { background: rgba(6,182,212,0.12); color: #06aab3; }
|
||||
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
|
||||
.wb-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
|
||||
.wb-done { background: rgba(5,150,82,0.13); color: #059652; }
|
||||
.wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
|
||||
.w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
|
||||
.w-author { font-weight: 700; color: var(--violet); }
|
||||
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.55; margin-top: 6px; white-space: pre-wrap; word-break: break-word; }
|
||||
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
|
||||
border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
|
||||
.w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
/* admin manage */
|
||||
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
|
||||
.w-sel { padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; font-family: 'Manrope', sans-serif;
|
||||
font-size: 0.8rem; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
|
||||
.w-sel:focus { border-color: var(--violet); }
|
||||
.w-note-inp { flex: 1; min-width: 200px; padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; outline: none; resize: vertical; min-height: 38px; }
|
||||
.w-note-inp:focus { border-color: var(--violet); }
|
||||
.w-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
|
||||
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; color: var(--text-2); cursor: pointer; transition: all .15s; }
|
||||
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
|
||||
.w-btn-primary:hover { opacity: .9; color: #fff; }
|
||||
.w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.w-btn-icon { padding: 8px; color: var(--text-3); }
|
||||
.w-btn-icon:hover { background: rgba(239,71,111,0.08); color: #EF476F; border-color: rgba(239,71,111,0.25); }
|
||||
|
||||
/* empty / skeleton */
|
||||
.w-empty { text-align: center; padding: 54px 20px; color: var(--text-3); }
|
||||
.w-empty-art { width: 80px; height: 80px; margin: 0 auto 14px; border-radius: 22px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(155,93,229,0.08); color: var(--violet); }
|
||||
.w-empty-art i { width: 38px; height: 38px; }
|
||||
.w-empty-t { font-size: 0.92rem; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
|
||||
.w-empty-s { font-size: 0.8rem; }
|
||||
.w-skel { height: 78px; border-radius: 16px; background: linear-gradient(90deg,#eef0f4 25%,#f6f7f9 50%,#eef0f4 75%); background-size: 200% 100%; animation: wqShim 1.3s infinite; }
|
||||
@keyframes wqShim { to { background-position: -200% 0; } }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 16px 14px 80px; }
|
||||
.wq-new-btn { width: 100%; justify-content: center; }
|
||||
.wq-search { margin-left: 0; max-width: none; }
|
||||
.w-card { padding: 13px; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
<div class="sb-content">
|
||||
<div class="container">
|
||||
|
||||
<div class="wq-hero">
|
||||
<div class="wq-hero-icon"><i data-lucide="lightbulb" style="width:24px;height:24px"></i></div>
|
||||
<div class="wq-hero-txt">
|
||||
<div class="page-title">Пожелания по улучшению</div>
|
||||
<div class="page-sub" id="w-sub">Есть идея, как сделать систему лучше? Расскажите — мы прочитаем и ответим.</div>
|
||||
</div>
|
||||
<button class="wq-new-btn" id="wq-new-btn" onclick="toggleForm()">
|
||||
<span id="wq-new-ic"><i data-lucide="plus" style="width:15px;height:15px"></i></span> <span id="wq-new-lbl">Поделиться идеей</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Submit form -->
|
||||
<div class="wq-form collapsed" id="wq-form">
|
||||
<div class="wq-flabel">Категория</div>
|
||||
<div class="wq-cat-pick" id="wq-cat-pick"></div>
|
||||
<input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
|
||||
<textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
|
||||
<div class="wq-form-foot">
|
||||
<span class="wq-counter" id="wf-counter">0 / 200</span>
|
||||
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
|
||||
<i data-lucide="send" style="width:14px;height:14px"></i> Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wq-stats" id="wq-stats"></div>
|
||||
<div class="wq-subbar" id="wq-subbar" style="display:none">
|
||||
<div class="wq-cats" id="wq-cats"></div>
|
||||
<input class="wq-search" id="wq-search" placeholder="Поиск по пожеланиям…" oninput="onSearch(this.value)" />
|
||||
</div>
|
||||
|
||||
<div class="w-list" id="w-list">
|
||||
<div class="w-skel"></div><div class="w-skel"></div><div class="w-skel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script>
|
||||
const { user, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
LS.showBoardIfAllowed();
|
||||
LS.notif.init();
|
||||
|
||||
const CAT = {
|
||||
feature: { label: 'Новая функция', icon: 'sparkles', color: '#9B5DE5' },
|
||||
ui: { label: 'Интерфейс', icon: 'layout-panel-top', color: '#06B6D4' },
|
||||
content: { label: 'Контент', icon: 'book-open', color: '#2563EB' },
|
||||
bug: { label: 'Баг / ошибка', icon: 'bug', color: '#EF476F' },
|
||||
other: { label: 'Другое', icon: 'message-circle', color: '#64748B' },
|
||||
};
|
||||
const CAT_ORDER = ['feature', 'ui', 'content', 'bug', 'other'];
|
||||
const ST = {
|
||||
new: { label: 'Новое', icon: 'sparkle', color: '#06aab3' },
|
||||
planned: { label: 'Запланировано', icon: 'calendar-clock', color: '#9B5DE5' },
|
||||
in_progress: { label: 'В работе', icon: 'loader', color: '#d97706' },
|
||||
done: { label: 'Готово', icon: 'check-circle-2', color: '#059652' },
|
||||
declined: { label: 'Отклонено', icon: 'x-circle', color: '#64748B' },
|
||||
};
|
||||
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
|
||||
let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
function icons() { if (window.lucide) lucide.createIcons(); }
|
||||
|
||||
/* ── form ── */
|
||||
function renderCatPick() {
|
||||
document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
|
||||
`<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
|
||||
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
|
||||
icons();
|
||||
}
|
||||
function pickCat(k) { _formCat = k; renderCatPick(); }
|
||||
function updCounter() {
|
||||
const n = document.getElementById('wf-title').value.length;
|
||||
document.getElementById('wf-counter').textContent = n + ' / 200';
|
||||
}
|
||||
function toggleForm(forceOpen) {
|
||||
_formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
|
||||
document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
|
||||
const btn = document.getElementById('wq-new-btn');
|
||||
btn.classList.toggle('open', _formOpen);
|
||||
document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
|
||||
// lucide заменяет <i> на <svg> при рендере, поэтому пере-вставляем свежий <i> в контейнер.
|
||||
const ic = document.getElementById('wq-new-ic');
|
||||
if (ic) ic.innerHTML = `<i data-lucide="${_formOpen ? 'chevron-up' : 'plus'}" style="width:15px;height:15px"></i>`;
|
||||
icons();
|
||||
if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
|
||||
}
|
||||
|
||||
async function submitWish() {
|
||||
const title = document.getElementById('wf-title').value.trim();
|
||||
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
|
||||
const btn = document.getElementById('wf-submit');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
|
||||
if (isAdmin && user) { row.author_name = user.name; }
|
||||
_wishes.unshift(row);
|
||||
document.getElementById('wf-title').value = '';
|
||||
document.getElementById('wf-body').value = '';
|
||||
_formCat = 'feature'; renderCatPick(); updCounter();
|
||||
toggleForm(false);
|
||||
LS.toast('Пожелание отправлено — спасибо!', 'success');
|
||||
_statusFilter = null; _catFilter = null;
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
/* ── load + render ── */
|
||||
async function load() {
|
||||
try {
|
||||
const data = await LS.wishesList();
|
||||
_wishes = data.wishes || [];
|
||||
renderAll();
|
||||
} catch (e) {
|
||||
document.getElementById('w-list').innerHTML = `<div class="w-empty"><div class="w-empty-t">Не удалось загрузить</div><div class="w-empty-s">${esc(e.message || '')}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function counts() {
|
||||
const c = {}; ST_ORDER.forEach(s => c[s] = 0);
|
||||
_wishes.forEach(w => { c[w.status] = (c[w.status] || 0) + 1; });
|
||||
return c;
|
||||
}
|
||||
|
||||
function renderAll() { renderStats(); renderSubbar(); renderList(); }
|
||||
|
||||
function renderStats() {
|
||||
const c = counts();
|
||||
const total = _wishes.length;
|
||||
let html = `<button class="wq-stat${!_statusFilter ? ' active' : ''}" style="--sc:#9B5DE5" onclick="setStatus(null)">
|
||||
<span class="wq-stat-lbl">Все</span><span class="wq-stat-num">${total}</span></button>`;
|
||||
html += ST_ORDER.filter(s => c[s] > 0).map(s =>
|
||||
`<button class="wq-stat${_statusFilter === s ? ' active' : ''}" style="--sc:${ST[s].color}" onclick="setStatus('${s}')">
|
||||
<span class="wq-stat-dot"></span><span class="wq-stat-lbl">${ST[s].label}</span><span class="wq-stat-num">${c[s]}</span></button>`).join('');
|
||||
document.getElementById('wq-stats').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSubbar() {
|
||||
const cats = [...new Set(_wishes.map(w => w.category))];
|
||||
const bar = document.getElementById('wq-subbar');
|
||||
// показываем подбар только если есть смысл (несколько категорий или много пожеланий)
|
||||
if (cats.length < 2 && _wishes.length < 4) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = '';
|
||||
document.getElementById('wq-cats').innerHTML = CAT_ORDER.filter(k => cats.includes(k)).map(k =>
|
||||
`<button class="wq-cchip${_catFilter === k ? ' active' : ''}" style="--cc:${CAT[k].color}" onclick="setCat('${k}')">
|
||||
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
|
||||
document.getElementById('wq-search').style.display = _wishes.length >= 4 ? '' : 'none';
|
||||
icons();
|
||||
}
|
||||
|
||||
function setStatus(s) { _statusFilter = (_statusFilter === s) ? null : s; renderAll(); }
|
||||
function setCat(k) { _catFilter = (_catFilter === k) ? null : k; renderAll(); }
|
||||
function onSearch(v) { _q = v.trim().toLowerCase(); renderList(); }
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('w-list');
|
||||
let list = _wishes;
|
||||
if (_statusFilter) list = list.filter(w => w.status === _statusFilter);
|
||||
if (_catFilter) list = list.filter(w => w.category === _catFilter);
|
||||
if (_q) list = list.filter(w =>
|
||||
(w.title || '').toLowerCase().includes(_q) ||
|
||||
(w.body || '').toLowerCase().includes(_q) ||
|
||||
(w.author_name || '').toLowerCase().includes(_q));
|
||||
|
||||
if (!list.length) {
|
||||
const fresh = !_wishes.length;
|
||||
el.innerHTML = `<div class="w-empty">
|
||||
<div class="w-empty-art"><i data-lucide="${fresh ? 'lightbulb' : 'search-x'}"></i></div>
|
||||
<div class="w-empty-t">${fresh ? (isAdmin ? 'Пожеланий пока нет' : 'У вас пока нет пожеланий') : 'Ничего не найдено'}</div>
|
||||
<div class="w-empty-s">${fresh ? (isAdmin ? 'Они появятся здесь, когда пользователи их оставят.' : 'Поделитесь идеей — нажмите «Поделиться идеей» выше.') : 'Попробуйте изменить фильтр или запрос.'}</div>
|
||||
</div>`;
|
||||
icons();
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(cardHtml).join('');
|
||||
icons();
|
||||
}
|
||||
|
||||
function cardHtml(w) {
|
||||
const cat = CAT[w.category] || CAT.other;
|
||||
const st = ST[w.status] || ST.new;
|
||||
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span><span>·</span>` : '';
|
||||
const note = w.admin_note ? `<div class="w-note"><i data-lucide="message-square-reply"></i><div><b>Ответ:</b> ${esc(w.admin_note)}</div></div>` : '';
|
||||
let manage = '';
|
||||
if (isAdmin) {
|
||||
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST[s].label}</option>`).join('');
|
||||
manage = `<div class="w-manage">
|
||||
<select class="w-sel" id="st-${w.id}">${opts}</select>
|
||||
<textarea class="w-note-inp" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
|
||||
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})"><i data-lucide="check" style="width:13px;height:13px"></i> Сохранить</button>
|
||||
<button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
|
||||
</div>`;
|
||||
} else if (w.status === 'new') {
|
||||
manage = `<div class="w-manage"><button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Удалить</button></div>`;
|
||||
}
|
||||
return `<div class="w-card" style="--cc:${cat.color}">
|
||||
<div class="w-cat-ic"><i data-lucide="${cat.icon}"></i></div>
|
||||
<div class="w-main">
|
||||
<div class="w-head">
|
||||
<span class="w-title">${esc(w.title)}</span>
|
||||
<span class="w-badge wb-${w.status}"><i data-lucide="${st.icon}"></i>${st.label}</span>
|
||||
</div>
|
||||
<div class="w-meta">${author}<span>${cat.label}</span><span>·</span><span>${fmtDate(w.created_at)}</span></div>
|
||||
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
|
||||
${note}
|
||||
${manage}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveWish(id) {
|
||||
try {
|
||||
const upd = await LS.wishUpdate(id, {
|
||||
status: document.getElementById('st-' + id).value,
|
||||
admin_note: document.getElementById('note-' + id).value.trim(),
|
||||
});
|
||||
const i = _wishes.findIndex(w => w.id === id);
|
||||
if (i >= 0) { _wishes[i] = { ..._wishes[i], ...upd }; }
|
||||
LS.toast('Сохранено', 'success');
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
async function delWish(id) {
|
||||
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.wishDelete(id);
|
||||
_wishes = _wishes.filter(w => w.id !== id);
|
||||
renderAll();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
renderCatPick();
|
||||
load();
|
||||
icons();
|
||||
</script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -190,6 +190,12 @@ async function deleteClass(id) { return req('DELETE', `/classes/
|
||||
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
|
||||
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
|
||||
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
|
||||
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
|
||||
/* ── Пожелания по улучшению ── */
|
||||
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
|
||||
async function wishCreate(data) { return req('POST', '/wishes', data); }
|
||||
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
|
||||
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
|
||||
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
|
||||
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
|
||||
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
|
||||
@@ -826,10 +832,129 @@ async function loadFeatures() {
|
||||
_featuresCache = await apiFetch('/api/features');
|
||||
} catch { _featuresCache = {}; }
|
||||
_gamificationEnabled = _featuresCache.gamification !== false;
|
||||
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
|
||||
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
|
||||
return _featuresCache;
|
||||
}
|
||||
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; }
|
||||
|
||||
/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */
|
||||
const FEATURE_HREFS = {
|
||||
hangman: ['/hangman'],
|
||||
crossword: ['/crossword'],
|
||||
pet: ['/pet'],
|
||||
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
|
||||
collection: ['/collection.html', '/collection'],
|
||||
lab: ['/lab'],
|
||||
knowledge_map: ['/knowledge-map'],
|
||||
flashcards: ['/flashcards'],
|
||||
board: ['/board'],
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
classroom: ['/classroom'],
|
||||
sim_builder: ['/sim-builder', '/sim-builder.html'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
quantik: ['/quantik', '/quantik.html'],
|
||||
theory: ['/theory', '/theory.html'],
|
||||
sitemap: ['/sitemap', '/sitemap.html'],
|
||||
wishes: ['/wishes', '/wishes.html'],
|
||||
};
|
||||
/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только
|
||||
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
|
||||
Hero-карточки дашборда: у lab JS меняет href на /lab?sim=… → [href="/lab"] не
|
||||
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
|
||||
const FEATURE_WIDGETS = {
|
||||
flashcards: ['#w-flashcard'],
|
||||
// #hc-lab — hero-карточка дашборда; .tb-lab-btn — кнопка «открыть связанную
|
||||
// симуляцию» на карточках каталога учебников (openLabSim → /lab?sim=…). Это
|
||||
// <button onclick>, а не <a href="/lab">, поэтому [href="/lab"] её не ловит.
|
||||
lab: ['#hc-lab', '.tb-lab-btn'],
|
||||
pet: ['#hc-pet'],
|
||||
textbooks: ['#hc-read'],
|
||||
};
|
||||
/* Админ видит и имеет доступ ко ВСЕМУ, даже к отключённым модулям (он ими управляет).
|
||||
Поэтому для админа никакие скрытия/редиректы фич не применяются. getUser() читает
|
||||
localStorage синхронно (определён в начале файла) — работает и на ранней sync-инъекции. */
|
||||
function _isAdminUser() {
|
||||
try { return getUser()?.role === 'admin'; } catch { return false; }
|
||||
}
|
||||
|
||||
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
|
||||
на ранней загрузке (ДО построения сайдбара/виджетов) — против мигания (FOUC),
|
||||
затем обновляется по свежему /api/features. */
|
||||
function _applyFeatureCss(feats) {
|
||||
// Админ — без скрытий: чистим <style> и снимаем kill-switch геймификации.
|
||||
if (_isAdminUser()) {
|
||||
const elA = document.getElementById('ls-feat-hide');
|
||||
if (elA) elA.textContent = '';
|
||||
document.documentElement.classList.remove('no-gamification');
|
||||
return;
|
||||
}
|
||||
const sels = [];
|
||||
if (feats) {
|
||||
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
|
||||
if (feats[key] === false) {
|
||||
hrefs.forEach(h => sels.push(`[href="${h}"]`));
|
||||
(FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Скрытые exam-prep треки (подготовка): кэш хрефов с прошлой загрузки — против мигания.
|
||||
// /api/exam-prep/tracks асинхронен, поэтому держим точный список скрытых ссылок в кэше.
|
||||
try {
|
||||
JSON.parse(localStorage.getItem('ls_examhide') || '[]')
|
||||
.forEach(h => sels.push(`[href="${h}"]`));
|
||||
} catch { /* пусто */ }
|
||||
let css = sels.length ? sels.join(',') + '{display:none !important}' : '';
|
||||
// Геймификация: дублируем kill-switch в инъекцию — для страниц БЕЗ ls.css.
|
||||
// Учебники (frontend/textbooks/*.html) грузят api.js, но НЕ ls.css, поэтому правила
|
||||
// .no-gamification из ls.css туда не доходят, и встроенная XP-механика (data-gamified,
|
||||
// #ach-popup) оставалась видимой. Инъекция работает на любой странице с api.js.
|
||||
if (feats && feats.gamification === false) {
|
||||
css += '.no-gamification [data-gamified],.no-gamification #ach-popup{display:none!important}';
|
||||
}
|
||||
let el = document.getElementById('ls-feat-hide');
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = 'ls-feat-hide';
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
// Геймификация: класс на <html> (доступен раньше body) → kill-switch без мигания.
|
||||
if (feats) document.documentElement.classList.toggle('no-gamification', feats.gamification === false);
|
||||
}
|
||||
/* Ранняя синхронная попытка из кэша прошлой загрузки — нет мигания на повторных заходах.
|
||||
(FEATURE_HREFS — const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */
|
||||
try {
|
||||
const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
|
||||
_applyFeatureCss(_cachedFeats); // применит и кэш фич, и кэш скрытых exam-prep ссылок
|
||||
} catch { /* нет кэша / приватный режим — просто ждём async */ }
|
||||
|
||||
/* Авторитетно подтянуть фичи на страницах БЕЗ сайдбара (учебники, embed): там
|
||||
sidebar.js/hideDisabledFeatures не вызывают loadFeatures, и кэш мог устареть.
|
||||
loadFeatures() кэширует in-memory (дубль-вызов = один fetch) и сам зовёт _applyFeatureCss.
|
||||
Только для залогиненных — иначе на /login apiFetch поймает 401 и зациклит редирект. */
|
||||
try {
|
||||
if (isLoggedIn()) { loadFeatures().catch(() => {}); }
|
||||
} catch { /* defensive */ }
|
||||
|
||||
/* Прячет группы сайдбара (.sb-group), у которых не осталось ни одного видимого пункта,
|
||||
чтобы не висел пустой заголовок-аккордеон (напр. «Практика и игры», когда все
|
||||
модули отключены). Зовётся после построения сайдбара и после hideDisabledFeatures. */
|
||||
function hideEmptySidebarGroups() {
|
||||
document.querySelectorAll('.sb-group').forEach(g => {
|
||||
const body = g.querySelector('.sb-group-body');
|
||||
if (!body) return;
|
||||
let anyVisible = false;
|
||||
body.querySelectorAll('.sb-link').forEach(it => {
|
||||
const cs = getComputedStyle(it);
|
||||
if (cs.display !== 'none' && cs.visibility !== 'hidden') anyVisible = true;
|
||||
});
|
||||
g.style.display = anyVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show board sidebar link only for teachers/admins and students in a class.
|
||||
* Call after LS.initPage(). Uses features cache (_no_class flag).
|
||||
@@ -839,38 +964,25 @@ async function showBoardIfAllowed() {
|
||||
if (!el) return;
|
||||
const user = getUser();
|
||||
if (!user) return;
|
||||
if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; }
|
||||
// Student: check if in a class
|
||||
// Админ видит доску всегда (даже если фича отключена) — он ею управляет.
|
||||
if (user.role === 'admin') { el.style.display = ''; return; }
|
||||
const feats = await loadFeatures();
|
||||
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю.
|
||||
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
|
||||
// иначе она перекрывает скрытие из hideDisabledFeatures().
|
||||
if (feats.board === false) { el.style.display = 'none'; return; }
|
||||
if (user.role === 'teacher') { el.style.display = ''; return; }
|
||||
// Student: check if in a class
|
||||
if (!feats._no_class) el.style.display = '';
|
||||
}
|
||||
|
||||
async function hideDisabledFeatures() {
|
||||
const feats = await loadFeatures();
|
||||
const map = {
|
||||
hangman: ['/hangman'],
|
||||
crossword: ['/crossword'],
|
||||
pet: ['/pet'],
|
||||
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
|
||||
collection: ['/collection.html', '/collection'],
|
||||
lab: ['/lab'],
|
||||
knowledge_map: ['/knowledge-map'],
|
||||
flashcards: ['/flashcards'],
|
||||
board: ['/board'],
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
classroom: ['/classroom'],
|
||||
sim_builder: ['/sim-builder', '/sim-builder.html'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
quantik: ['/quantik', '/quantik.html'],
|
||||
};
|
||||
for (const [key, hrefs] of Object.entries(map)) {
|
||||
const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
|
||||
// Админ видит и открывает всё — никаких скрытий, редиректов и схлопывания групп.
|
||||
if (_isAdminUser()) return;
|
||||
// Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
|
||||
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
|
||||
if (feats[key] === false) {
|
||||
hrefs.forEach(href => {
|
||||
document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none');
|
||||
});
|
||||
// Redirect away if currently on a disabled page
|
||||
const cur = window.location.pathname;
|
||||
if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) {
|
||||
window.location.href = '/dashboard.html';
|
||||
@@ -878,7 +990,7 @@ async function hideDisabledFeatures() {
|
||||
}
|
||||
}
|
||||
if (feats.gamification === false) {
|
||||
document.body.classList.add('no-gamification');
|
||||
document.body.classList.add('no-gamification'); // дубль на body (html-класс ставит _applyFeatureCss)
|
||||
// If student is already viewing achievements or shop tab, redirect to account tab
|
||||
const active = document.querySelector('#tab-achievements.active, #tab-shop.active');
|
||||
if (active) {
|
||||
@@ -894,10 +1006,16 @@ async function hideDisabledFeatures() {
|
||||
try {
|
||||
const data = await apiFetch('/api/exam-prep/tracks');
|
||||
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
|
||||
// Собираем точные хрефы скрытых треков и кэшируем — чтобы на СЛЕДУЮЩЕЙ загрузке
|
||||
// _applyFeatureCss спрятал их синхронно из кэша ещё до сборки сайдбара (без мигания).
|
||||
const hide = [];
|
||||
examLinks.forEach(el => {
|
||||
const m = (el.getAttribute('href') || '').match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (m && !allowed.has(m[1])) el.style.display = 'none';
|
||||
const href = el.getAttribute('href') || '';
|
||||
const m = href.match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (m && !allowed.has(m[1])) hide.push(href);
|
||||
});
|
||||
try { localStorage.setItem('ls_examhide', JSON.stringify(hide)); } catch {}
|
||||
_applyFeatureCss(_featuresCache); // обновить <style> (скрыть запрещённые, ПОКАЗАТЬ снова разрешённые)
|
||||
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
|
||||
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
|
||||
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
|
||||
@@ -928,6 +1046,9 @@ async function hideDisabledFeatures() {
|
||||
document.body.classList.add('no-class');
|
||||
document.body.classList.add('no-gamification'); // no class <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> no gamification
|
||||
}
|
||||
|
||||
// В самом конце — после всех скрытий (фичи, exam-prep, no_class) — схлопнуть пустые группы.
|
||||
hideEmptySidebarGroups();
|
||||
}
|
||||
|
||||
/* ── generic authenticated fetch (full path like /api/courses) ─────── */
|
||||
@@ -1029,7 +1150,8 @@ window.LS = {
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal,
|
||||
regenerateInviteCode, classJournal, classOutstanding,
|
||||
wishesList, wishCreate, wishUpdate, wishDelete,
|
||||
joinClass, myClasses, getStudents, classFeed,
|
||||
getAnnouncements, createAnnouncement, deleteAnnouncement,
|
||||
getNotifications, markNotifRead, markAllNotifsRead, connectSSE,
|
||||
@@ -1061,9 +1183,10 @@ window.LS = {
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
gameProgressList, gameProgressSubmit,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth,
|
||||
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
|
||||
prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass,
|
||||
escapeHtml, esc,
|
||||
@@ -1089,6 +1212,7 @@ window.LS = {
|
||||
loadFeatures,
|
||||
clearFeaturesCache,
|
||||
hideDisabledFeatures,
|
||||
hideEmptySidebarGroups,
|
||||
showBoardIfAllowed,
|
||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
|
||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||
@@ -1299,7 +1423,43 @@ async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', {
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); }
|
||||
async function assistantAsk(q, context, history, mode) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined, mode: mode || undefined }); }
|
||||
// Стриминговый ask: SSE поверх POST (fetch-stream). cbs: { onMeta, onDelta, onDone }.
|
||||
async function assistantAskStream(q, context, history, mode, cbs) {
|
||||
cbs = cbs || {};
|
||||
const token = getToken();
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(API + '/assistant/ask/stream', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ q, context: context || undefined, history: history || undefined, mode: mode || undefined }),
|
||||
});
|
||||
if (!res.ok || !res.body) throw Object.assign(new Error('stream failed'), { status: res.status });
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
const handle = (block) => {
|
||||
let ev = 'message', data = '';
|
||||
block.split('\n').forEach((ln) => {
|
||||
if (ln.indexOf('event:') === 0) ev = ln.slice(6).trim();
|
||||
else if (ln.indexOf('data:') === 0) data += ln.slice(5).trim();
|
||||
});
|
||||
if (!data) return;
|
||||
let obj; try { obj = JSON.parse(data); } catch (e) { return; }
|
||||
if (ev === 'delta' && cbs.onDelta) cbs.onDelta(obj.t || '');
|
||||
else if (ev === 'meta' && cbs.onMeta) cbs.onMeta(obj);
|
||||
else if (ev === 'done' && cbs.onDone) cbs.onDone(obj);
|
||||
};
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); if (block.trim()) handle(block); }
|
||||
}
|
||||
if (buf.trim()) handle(buf);
|
||||
}
|
||||
async function assistantFlashcards(text, title, count) { return req('POST', '/assistant/flashcards', { text, title, count }); }
|
||||
async function assistantQuestions(text, count) { return req('POST', '/assistant/questions', { text, count }); }
|
||||
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
|
||||
async function assistantMemory() { return req('GET', '/assistant/memory'); }
|
||||
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
|
||||
@@ -1313,6 +1473,10 @@ async function adminSaveProvider(d) { return req('POST', '/admin/assistant
|
||||
async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); }
|
||||
async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); }
|
||||
async function adminAssistantModels(params) { const q = new URLSearchParams(params || {}).toString(); return req('GET', '/admin/assistant/models' + (q ? '?' + q : '')); }
|
||||
async function adminAssistantScan(id) { return req('POST', '/admin/assistant/scan', id ? { id } : {}); }
|
||||
async function adminAssistantProbe(id, model) { return req('POST', '/admin/assistant/probe', { id, model }); }
|
||||
async function adminAssistantApplyModels(models, reset) { return req('POST', '/admin/assistant/models/apply', reset ? { reset: true } : { models }); }
|
||||
async function adminAssistantHealth() { return req('POST', '/admin/assistant/health', {}); }
|
||||
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||
@@ -1854,3 +2018,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Глобальный репортер клиентских ошибок ───────────────────────────────
|
||||
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
|
||||
и шлёт в /api/client-errors → они появляются в админ-вкладке «Ошибки».
|
||||
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
|
||||
(function initClientErrorReporter() {
|
||||
const seen = new Set();
|
||||
let sent = 0; const MAX_PER_PAGE = 15;
|
||||
let inFlight = false;
|
||||
|
||||
function send(payload) {
|
||||
try {
|
||||
if (!isLoggedIn()) return; // отчёты только от залогиненных
|
||||
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
|
||||
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
|
||||
if (seen.has(sig)) return;
|
||||
seen.add(sig); sent++;
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
const token = getToken();
|
||||
fetch(API + '/client-errors', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true, // долетит даже при закрытии вкладки
|
||||
}).catch(function () {}).finally(function () { inFlight = false; });
|
||||
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
|
||||
}
|
||||
|
||||
window.addEventListener('error', function (e) {
|
||||
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
|
||||
if (!e || (!e.message && !e.error)) return;
|
||||
send({
|
||||
kind: 'error',
|
||||
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
|
||||
stack: e.error && e.error.stack ? String(e.error.stack) : null,
|
||||
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
|
||||
url: location.pathname + location.search + location.hash,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', function (e) {
|
||||
const r = e && e.reason;
|
||||
let msg = 'Unhandled promise rejection';
|
||||
let stack = null;
|
||||
if (r) {
|
||||
if (typeof r === 'string') msg = r;
|
||||
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
|
||||
}
|
||||
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||||
${L('/dashboard', 'home', 'Дашборд')}
|
||||
${L('/sitemap', 'map', 'Путеводитель')}
|
||||
${L('/wishes', 'lightbulb', 'Пожелания')}
|
||||
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
|
||||
${G('learning', 'Учебный процесс', `
|
||||
@@ -228,6 +229,9 @@
|
||||
LS.showBoardIfAllowed?.();
|
||||
LS.hideDisabledFeatures?.();
|
||||
LS.notif?.init?.();
|
||||
// Синхронно по кэш-состоянию (CSS уже инъектнут до сборки) — прячем пустые
|
||||
// группы сразу, без мигания; hideDisabledFeatures повторит после свежих данных.
|
||||
LS.hideEmptySidebarGroups?.();
|
||||
}
|
||||
|
||||
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Тригонометрическая окружность — план улучшения (тренажёр темы, без функций)
|
||||
|
||||
Цель: симуляция `frontend/js/labs/trigcircle.js` + панель `frontend/labs-bodies.html` (#sim-trigcircle)
|
||||
покрывает всю школьную тригонометрию НА ОКРУЖНОСТИ. Графики y=f(x) («функции») — вне темы:
|
||||
существующий showGraph оставляем опциональным/скрываемым.
|
||||
|
||||
Архитектура: рукописный canvas-sim (класс TrigCircleSim) + HTML-панель в labs-bodies.html +
|
||||
glue-функции (`_openTrigCircle`, `trigToggle`, `trigGoTo`, `trigReset`, `_trigUpdateUI`) внизу
|
||||
trigcircle.js; регистрация в `_register-all.js` (`trigcircle`). KaTeX, LabFX, _tasks.js доступны.
|
||||
⛔ без eval, без эмодзи (inline SVG .ic), всё аддитивно (не ломать текущий режим).
|
||||
|
||||
## Уже есть
|
||||
Окружность, перетаскиваемая точка, угол °/рад (метки π/6…), sin/cos/tan/cot отрезками (слои),
|
||||
треугольник sin-cos, касательная/котангенс, 16 табличных углов + snap, подсветка четверти,
|
||||
значения дробями (½,√2/2,√3/2,√3/3,√3), stat-bar, опциональный график (= «функции»).
|
||||
|
||||
## Статус (на 2026-06-24) — ВСЕ ОСНОВНЫЕ ФАЗЫ ГОТОВЫ
|
||||
- ✅ Ф1 (углы: ввод, котерминальность, 16 углов, опорный угол, знаки) — d395e10
|
||||
- ✅ Ф2 (точные значения + формулы приведения для текущего угла) — 5eed248
|
||||
- ✅ KaTeX: формулы — cefb5e0; ВСЯ панель (значения, угол, таблица) — 244df71
|
||||
- ✅ Ф3 (знаки по четвертям): _quadSigns на canvas (текущая четверть подсвечена) +
|
||||
панельная строка знаков (Ф1). Доп. работы не потребовалось.
|
||||
- ✅ Ф4 (таблица значений 0–90° на KaTeX, подсветка опорного угла) — fe6df8f
|
||||
- ✅ Ф5 (чётность −α: зеркальная точка + sin/cos/tg(−α) + периоды) — 48158ea;
|
||||
формулы приведения — Ф2.
|
||||
- ✅ Ф6 (простейшие уравнения fn(x)=a: все решения на круге + общая формула KaTeX) — dfa0535
|
||||
- Уже было на canvas до плана: Пифагор (_pythBar), отрезки sin/cos/tg/ctg, координаты
|
||||
точки, табличные точки+snap, опц. график функций.
|
||||
- Осталось (опционально): режимы-вкладки + задания (_tasks.js); sec/csc; два угла (Ф7).
|
||||
|
||||
## Фазы
|
||||
- **Ф1 — Углы и обзор**: тумблер скрыть график (фокус на круге); ввод угла (° и π-доли);
|
||||
полная сетка табличных кнопок (16); опорный (острый) угол; знаки по четвертям в выводе;
|
||||
подсказка котерминальности (+360°k / +2πk).
|
||||
- **Ф2 — Определения / 6 функций**: подписи sin=y, cos=x на осях; слой sec/csc; Пифагор
|
||||
sin²+cos²=1 (гипотенуза=1) с формулой; тумблер «формула значения» (KaTeX).
|
||||
- **Ф3 — Знаки**: режим со знаками +/− sin/cos/tg по четвертям, мнемоника, таблица.
|
||||
- **Ф4 — Особые углы / таблица значений**: оверлей-таблица 0/30/45/60/90… с подсветкой текущего.
|
||||
- **Ф5 — Симметрии и формулы приведения**: чётность (α→−α), приведение (π±α, π/2±α, 2π−α)
|
||||
с анимацией отражения/поворота + KaTeX; период tg/ctg = π.
|
||||
- **Ф6 — Простейшие уравнения**: задаёшь значение → все решения на круге + общая формула
|
||||
(sin α=½ → π/6+2πk, 5π/6+2πk); для tg — шаг π; связка с arcsin/arccos геометрически.
|
||||
- **Ф7 — Два угла (опц.)**: вторая точка β → α±β, формулы сложения.
|
||||
- **Сквозное**: режимы-вкладки (Углы·Определения·Знаки·Особые·Приведение·Уравнения) с краткой
|
||||
теорией и кнопкой «Задание» через _tasks.js; шпаргалка (значения+знаки+тождества+приведение).
|
||||
|
||||
## Проверка каждой фазы
|
||||
node --check; headless-смоук математики (опорный угол, знаки, решения уравнений, приведение)
|
||||
в vm с стабом canvas; коммит+push, без эмодзи, lint.
|
||||
@@ -183,6 +183,30 @@ function Backup-Db {
|
||||
Prune-Backups
|
||||
}
|
||||
|
||||
function Reset-System {
|
||||
Write-Host ''
|
||||
Write-Host ' ============================================================' -ForegroundColor Red
|
||||
Write-Host ' ВНИМАНИЕ: ЧИСТЫЙ ЗАПУСК - НЕОБРАТИМАЯ ОЧИСТКА' -ForegroundColor Red
|
||||
Write-Host ' ============================================================' -ForegroundColor Red
|
||||
Write-Host ' УДАЛЯТСЯ: все пользователи (кроме одного админа), классы,' -ForegroundColor Yellow
|
||||
Write-Host ' задания, сессии, геймификация, уведомления, прогресс, история.' -ForegroundColor Yellow
|
||||
Write-Host ' СОХРАНЯТСЯ: учебники, вопросы, тесты, курсы, уроки, exam-prep,' -ForegroundColor Gray
|
||||
Write-Host ' симуляции, настройки/права и один админ (контент переходит ему).' -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
Write-Host ' План (предпросмотр, без изменений):' -ForegroundColor Cyan
|
||||
try { & node scripts/reset-system.js | Out-Host } catch { Write-Host (' Ошибка плана: ' + $_.Exception.Message) -ForegroundColor Red; return }
|
||||
Write-Host ''
|
||||
$ans = (Read-Host ' Для подтверждения введите СБРОС (иначе отмена)').Trim().ToUpper()
|
||||
if ($ans -ne 'СБРОС' -and $ans -ne 'RESET') { Write-Host ' Отменено.' -ForegroundColor Yellow; return }
|
||||
if (Server-Proc) { Write-Host ' Сервер работает - остановите его ([2]) перед сбросом для надёжности.' -ForegroundColor Yellow }
|
||||
Write-Host ' Шаг 1/2: бэкап БД...' -ForegroundColor Cyan
|
||||
Backup-Db
|
||||
Write-Host ' Шаг 2/2: очистка...' -ForegroundColor Cyan
|
||||
try { & node scripts/reset-system.js --apply --confirm=RESET | Out-Host }
|
||||
catch { Write-Host (' Ошибка сброса: ' + $_.Exception.Message) -ForegroundColor Red; return }
|
||||
Write-Host ' Готово. Перезапустите сервер ([3]). Бэкап сохранён в data\backups.' -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Restore-Db {
|
||||
if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return }
|
||||
$files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
|
||||
@@ -389,6 +413,7 @@ while ($run) {
|
||||
Menu-Row '[5]' 'Применить миграции' '[A]' 'Создать админа'
|
||||
Menu-Row ' ' '' '[W]' 'Сторож (авто-рестарт)'
|
||||
Menu-Row ' ' '' '[E]' 'Ошибки в логах'
|
||||
Menu-Row ' ' '' '[Z]' 'Сброс системы (чистый запуск)'
|
||||
Write-Host ''
|
||||
Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' ''
|
||||
Write-Host ' ' -NoNewline
|
||||
@@ -417,6 +442,7 @@ while ($run) {
|
||||
'^(U|Г)$' { Run-Cmd 'Обновление из репозитория' { Update-FromRepo }; Refresh-Status }
|
||||
'^(W|Ц)$' { Watchdog; Refresh-Status }
|
||||
'^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } }
|
||||
'^(Z|Я)$' { Reset-System; Refresh-Status; Start-Sleep 1 }
|
||||
'^0$' { $run = $false }
|
||||
default { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user