feat(trainer): P13 — конструктор параметрических генераторов
- custom_generators (мигр.084, spec_json + draft/published); customGeneratorController: validateGenSpec без исполнения (лимиты/типы), CRUD own+published + ownership - /api/practice/generators[/:id]; клиент LS.practiceGen* - страница /trainer-builder (учитель): форма (pick/derive/lhs/rhs/display/answer/solution) + живое превью через TE.instantiate(strict) (материализация + проверка ответа подстановкой) + список своих (правка/удаление/публикация) - тренажёр грузит свои+опубликованные генераторы в тему «Мои генераторы» (пошаговый режим работает); пункт сайдбара /trainer-builder (teacher-only) - тесты custom-generators.test.js 12/12; смоук движка 402/402 (T17 кастомный спек + strict-валидация); страница 33/33; ROADMAP_V2 P13 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
/* Пользовательские генераторы тренажёра (конструктор, P13).
|
||||
*
|
||||
* Спек генератора — ДАННЫЕ; на клиенте его исполняет безопасный SimExpr (⛔ без
|
||||
* eval). Сервер НЕ исполняет — только валидирует структуру/лимиты и хранит.
|
||||
* Текст НЕ экранируется на сервере: клиент рендерит безопасно (textContent / esc),
|
||||
* а выражения проходят через SimExpr. Стиль — customSimController/studentMaterials:
|
||||
* read auth-only (own + published), мутации — requireRole + per-row ownership.
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
const KINDS = { solve: 1, compute: 1, roots: 1, simplify: 1, inequality: 1 };
|
||||
const MAX_SPEC = 20000;
|
||||
|
||||
function clip(v, n) { return (typeof v === 'string') ? (v.length > n ? v.slice(0, n) : v) : ''; }
|
||||
function expr(v, n) { return (typeof v === 'string') ? clip(v.trim(), n || 200) : ''; }
|
||||
const NAME = /^[a-zA-Z][a-zA-Z0-9]{0,12}$/;
|
||||
|
||||
/* Валидация спека БЕЗ исполнения: типы/лимиты. Возврат { ok, clean?, error? }. */
|
||||
function validateGenSpec(spec) {
|
||||
if (!spec || typeof spec !== 'object') return { ok: false, error: 'спек отсутствует' };
|
||||
if (JSON.stringify(spec).length > MAX_SPEC) return { ok: false, error: 'спек слишком большой' };
|
||||
|
||||
const title = clip(String(spec.title || '').trim(), 120);
|
||||
if (!title) return { ok: false, error: 'нужен заголовок' };
|
||||
const topic = clip(String(spec.topic || 'custom').trim(), 60) || 'custom';
|
||||
const kind = (typeof spec.kind === 'string' && KINDS[spec.kind]) ? spec.kind : 'solve';
|
||||
|
||||
// pick: имя → [min,max] целые
|
||||
const pick = {};
|
||||
if (spec.pick && typeof spec.pick === 'object') {
|
||||
for (const k of Object.keys(spec.pick).slice(0, 20)) {
|
||||
const r = spec.pick[k];
|
||||
if (NAME.test(k) && Array.isArray(r) && r.length === 2 && Number.isInteger(r[0]) && Number.isInteger(r[1])) {
|
||||
pick[k] = [r[0], r[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
// derive: имя → формула (строка)
|
||||
const derive = {};
|
||||
if (spec.derive && typeof spec.derive === 'object') {
|
||||
for (const k of Object.keys(spec.derive).slice(0, 30)) {
|
||||
if (NAME.test(k) && typeof spec.derive[k] === 'string') derive[k] = expr(spec.derive[k]);
|
||||
}
|
||||
}
|
||||
// solution: [{ note, tex }]
|
||||
let solution = [];
|
||||
if (Array.isArray(spec.solution)) {
|
||||
solution = spec.solution.slice(0, 12).map(st => ({
|
||||
note: clip(String((st && st.note) || ''), 300),
|
||||
tex: expr(st && st.tex)
|
||||
}));
|
||||
}
|
||||
// answers: массив выражений (kind roots)
|
||||
let answers;
|
||||
if (Array.isArray(spec.answers)) answers = spec.answers.slice(0, 6).map(a => expr(a)).filter(Boolean);
|
||||
|
||||
const clean = {
|
||||
title, topic, kind,
|
||||
pick,
|
||||
derive: Object.keys(derive).length ? derive : undefined,
|
||||
constraint: expr(spec.constraint) || undefined,
|
||||
require: expr(spec.require) || undefined,
|
||||
lhs: expr(spec.lhs) || 'x',
|
||||
rhs: expr(spec.rhs) || 'x',
|
||||
display: (typeof spec.display === 'string' && spec.display.trim()) ? clip(spec.display, 200) : undefined,
|
||||
srcExpr: expr(spec.srcExpr) || undefined,
|
||||
answerExpr: expr(spec.answerExpr) || undefined,
|
||||
dispOp: ['<', '>', '<=', '>='].indexOf(spec.dispOp) !== -1 ? spec.dispOp : undefined,
|
||||
relOp: ['<', '>', '<=', '>='].indexOf(spec.relOp) !== -1 ? spec.relOp : undefined,
|
||||
bound: expr(spec.bound) || undefined,
|
||||
answer: expr(spec.answer) || undefined,
|
||||
answers: (answers && answers.length) ? answers : undefined,
|
||||
answerVar: /^[a-z]$/.test(spec.answerVar) ? spec.answerVar : 'x',
|
||||
integerAnswer: !!spec.integerAnswer,
|
||||
solution
|
||||
};
|
||||
Object.keys(clean).forEach(k => clean[k] === undefined && delete clean[k]);
|
||||
return { ok: true, clean };
|
||||
}
|
||||
|
||||
/* Строка БД → объект-генератор для клиента (готов к TE.instantiate). */
|
||||
function toClientGen(row) {
|
||||
let spec = {};
|
||||
try { spec = JSON.parse(row.spec_json) || {}; } catch (e) { spec = {}; }
|
||||
spec.id = 'cg' + row.id; // ключ навыка/прогресса
|
||||
spec.title = row.title;
|
||||
spec.topic = row.topic || 'custom';
|
||||
spec.dbid = row.id;
|
||||
spec.owner_id = row.owner_id;
|
||||
spec.status = row.status;
|
||||
spec._custom = true;
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* GET /api/practice/generators — свои + опубликованные. */
|
||||
function genList(req, res) {
|
||||
const uid = req.user.id;
|
||||
const rows = db.prepare(
|
||||
"SELECT * FROM custom_generators WHERE owner_id = ? OR status = 'published' ORDER BY updated_at DESC, id DESC"
|
||||
).all(uid);
|
||||
res.json({ generators: rows.map(toClientGen) });
|
||||
}
|
||||
|
||||
/* GET /api/practice/generators/:id — свой или опубликованный. */
|
||||
// @public-by-design: auth-only; видимость own+published проверяется в хендлере.
|
||||
function genGet(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && row.status !== 'published' && role !== 'admin') return res.status(403).json({ error: 'нет доступа' });
|
||||
res.json({ generator: toClientGen(row) });
|
||||
}
|
||||
|
||||
function genCreate(req, res) {
|
||||
const v = validateGenSpec(req.body && req.body.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
const status = (req.body && req.body.status === 'published') ? 'published' : 'draft';
|
||||
const info = db.prepare(
|
||||
'INSERT INTO custom_generators (owner_id, title, topic, spec_json, status) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(req.user.id, v.clean.title, v.clean.topic, JSON.stringify(v.clean), status);
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(info.lastInsertRowid);
|
||||
res.json({ ok: true, generator: toClientGen(row) });
|
||||
}
|
||||
|
||||
function genUpdate(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||
|
||||
let title = row.title, topic = row.topic, specJson = row.spec_json;
|
||||
if (req.body && req.body.spec) {
|
||||
const v = validateGenSpec(req.body.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
title = v.clean.title; topic = v.clean.topic; specJson = JSON.stringify(v.clean);
|
||||
}
|
||||
const status = (req.body && (req.body.status === 'published' || req.body.status === 'draft')) ? req.body.status : row.status;
|
||||
db.prepare("UPDATE custom_generators SET title = ?, topic = ?, spec_json = ?, status = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(title, topic, specJson, status, row.id);
|
||||
const upd = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(row.id);
|
||||
res.json({ ok: true, generator: toClientGen(upd) });
|
||||
}
|
||||
|
||||
function genDelete(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
|
||||
if (!row) return res.status(404).json({ error: 'не найдено' });
|
||||
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
|
||||
db.prepare('DELETE FROM custom_generators WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { validateGenSpec, genList, genGet, genCreate, genUpdate, genDelete };
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 084: Пользовательские генераторы тренажёра (конструктор, Roadmap P13).
|
||||
--
|
||||
-- Учитель создаёт ПАРАМЕТРИЧЕСКИЙ генератор задач — это ДАННЫЕ (spec_json):
|
||||
-- диапазоны pick, формулы derive, шаблоны lhs/rhs, ответ, шаги решения. На
|
||||
-- клиенте спек исполняет БЕЗОПАСНЫЙ SimExpr (⛔ без eval), на сервере он только
|
||||
-- хранится и валидируется по структуре/лимитам (НЕ исполняется). Прогресс по
|
||||
-- такому навыку ключуется как 'cg<id>'.
|
||||
-- status: draft (видит только автор) | published (видят и ученики).
|
||||
-- owner_id ON DELETE CASCADE — генераторы удаляются вместе с автором.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_generators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
topic TEXT NOT NULL DEFAULT 'custom',
|
||||
spec_json TEXT NOT NULL, -- полный спек генератора (данные)
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_generators_owner ON custom_generators (owner_id, status);
|
||||
@@ -22,4 +22,12 @@ router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass);
|
||||
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
|
||||
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
|
||||
|
||||
// Конструктор генераторов (P13): чтение — own+published; мутации — учитель/админ + ownership.
|
||||
const cg = require('../controllers/customGeneratorController');
|
||||
router.get('/generators', cg.genList);
|
||||
router.post('/generators', requireRole('teacher', 'admin'), cg.genCreate);
|
||||
router.get('/generators/:id', cg.genGet); // @public-by-design: own/published в хендлере
|
||||
router.put('/generators/:id', requireRole('teacher', 'admin'), cg.genUpdate);
|
||||
router.delete('/generators/:id', requireRole('teacher', 'admin'), cg.genDelete);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests: конструктор генераторов тренажёра (P13) — валидация + CRUD + доступ.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
const cg = require('../src/controllers/customGeneratorController');
|
||||
|
||||
app.use('/api/practice', require('../src/routes/practice'));
|
||||
after(() => cleanup());
|
||||
|
||||
const SPEC = {
|
||||
title: 'Моё уравнение', topic: 'custom', kind: 'solve',
|
||||
pick: { a: [2, 9], b: [1, 20], root: [-9, 9] },
|
||||
derive: { c: 'a*root + b', cmb: 'a*root' },
|
||||
require: 'root != 0',
|
||||
lhs: '{a}*x + {b}', rhs: '{c}', answer: 'root', integerAnswer: true,
|
||||
solution: [{ note: 'делим на {a}', tex: 'x = {cmb} / {a}' }]
|
||||
};
|
||||
|
||||
describe('validateGenSpec', () => {
|
||||
it('принимает корректный спек', () => {
|
||||
const v = cg.validateGenSpec(SPEC);
|
||||
assert.equal(v.ok, true, v.error);
|
||||
assert.equal(v.clean.kind, 'solve');
|
||||
assert.deepEqual(v.clean.pick.a, [2, 9]);
|
||||
assert.equal(v.clean.integerAnswer, true);
|
||||
});
|
||||
it('отвергает без заголовка', () => {
|
||||
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { title: '' })).ok, false);
|
||||
});
|
||||
it('фильтрует нецелые диапазоны pick', () => {
|
||||
const v = cg.validateGenSpec(Object.assign({}, SPEC, { pick: { a: [1.5, 9], b: [1, 20] } }));
|
||||
assert.equal(v.ok, true);
|
||||
assert.equal(v.clean.pick.a, undefined, 'нецелый диапазон отброшен');
|
||||
assert.deepEqual(v.clean.pick.b, [1, 20]);
|
||||
});
|
||||
it('отвергает слишком большой спек', () => {
|
||||
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { display: 'x'.repeat(30000) })).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/practice/generators CRUD', () => {
|
||||
let teacher, other, student, gid;
|
||||
before(async () => {
|
||||
teacher = (await getToken('teacher')).token;
|
||||
other = (await getToken('teacher')).token;
|
||||
student = (await getToken('student')).token;
|
||||
});
|
||||
|
||||
it('учитель создаёт генератор', async () => {
|
||||
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.ok(/^cg\d+$/.test(res.body.generator.id), 'id вида cg<dbid>');
|
||||
gid = res.body.generator.dbid;
|
||||
});
|
||||
|
||||
it('ученику создавать запрещено (403)', async () => {
|
||||
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, student);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('невалидный спек → 400', async () => {
|
||||
const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, teacher);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('автор видит свой генератор в списке', async () => {
|
||||
const res = await inject('GET', '/api/practice/generators', null, teacher);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.generators.some(g => g.dbid === gid), 'свой генератор в списке');
|
||||
});
|
||||
|
||||
it('чужой draft не виден другому учителю', async () => {
|
||||
const res = await inject('GET', '/api/practice/generators', null, other);
|
||||
assert.ok(!res.body.generators.some(g => g.dbid === gid), 'чужой draft скрыт');
|
||||
});
|
||||
|
||||
it('чужой не может изменить (403)', async () => {
|
||||
const res = await inject('PUT', '/api/practice/generators/' + gid, { spec: SPEC }, other);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('публикация делает генератор видимым другим', async () => {
|
||||
const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, teacher);
|
||||
assert.equal(pub.status, 200);
|
||||
assert.equal(pub.body.generator.status, 'published');
|
||||
const res = await inject('GET', '/api/practice/generators', null, other);
|
||||
assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден другому');
|
||||
});
|
||||
|
||||
it('автор удаляет свой генератор', async () => {
|
||||
const res = await inject('DELETE', '/api/practice/generators/' + gid, null, teacher);
|
||||
assert.equal(res.status, 200);
|
||||
const after = await inject('GET', '/api/practice/generators/' + gid, null, teacher);
|
||||
assert.equal(after.status, 404, 'после удаления 404');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Конструктор генераторов — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/css/ls.css"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
|
||||
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ink:#1b1f38; --ink-soft:#5b6378; --ink-faint:#98a1b8;
|
||||
--g1:#6366f1; --g2:#8b5cf6; --accent-ink:#4338ca; --accent-soft:#eef0ff;
|
||||
--ok:#10b981; --ok-ink:#047857; --bad:#ef4444;
|
||||
--sh:0 16px 40px rgba(27,31,56,.09), 0 2px 6px rgba(27,31,56,.04);
|
||||
--ease:cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
.sb-content {
|
||||
background-color:#f5f6fb;
|
||||
background-image:
|
||||
radial-gradient(1000px 600px at 86% -10%, rgba(139,92,246,.10), transparent 60%),
|
||||
radial-gradient(820px 560px at 2% -6%, rgba(99,102,241,.09), transparent 55%),
|
||||
linear-gradient(rgba(99,102,241,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99,102,241,.05) 1px, transparent 1px);
|
||||
background-size:100% 100%,100% 100%,26px 26px,26px 26px; background-attachment:fixed;
|
||||
}
|
||||
.gb-wrap { max-width:1080px; margin:0 auto; padding:30px 20px 90px; }
|
||||
.gb-h1 { font-family:'Manrope',sans-serif; font-weight:800; font-size:clamp(1.5rem,4vw,2rem); letter-spacing:-.02em; color:var(--ink); margin:0 0 4px; }
|
||||
.gb-sub { color:var(--ink-soft); font-size:.95rem; margin-bottom:22px; }
|
||||
.gb-grid { display:grid; grid-template-columns:300px 1fr; gap:20px; align-items:start; }
|
||||
@media (max-width:880px){ .gb-grid { grid-template-columns:1fr; } }
|
||||
|
||||
.gb-card { background:#fff; border:1px solid rgba(99,102,241,.1); border-radius:18px; box-shadow:var(--sh); padding:20px; }
|
||||
.gb-card h2 { font-family:'Manrope',sans-serif; font-size:1rem; font-weight:800; color:var(--ink); margin:0 0 14px; }
|
||||
|
||||
.gb-list-item { display:flex; align-items:center; gap:8px; padding:10px 12px; border-radius:12px; border:1px solid rgba(99,102,241,.12); margin-bottom:8px; background:#fbfbff; }
|
||||
.gb-li-main { flex:1; min-width:0; cursor:pointer; }
|
||||
.gb-li-title { font-weight:700; color:var(--ink); font-size:.9rem; }
|
||||
.gb-li-meta { font-size:.74rem; color:var(--ink-faint); }
|
||||
.gb-li-pub { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; padding:2px 7px; border-radius:99px; }
|
||||
.gb-li-pub.draft { background:rgba(148,163,184,.16); color:#64748b; }
|
||||
.gb-li-pub.published { background:var(--ok); color:#fff; }
|
||||
.gb-icon-btn { background:none; border:none; cursor:pointer; color:var(--ink-faint); padding:4px; border-radius:8px; }
|
||||
.gb-icon-btn:hover { background:rgba(99,102,241,.1); color:var(--accent-ink); }
|
||||
.gb-icon-btn .ic { width:16px; height:16px; }
|
||||
.gb-empty { color:var(--ink-faint); font-size:.85rem; text-align:center; padding:14px; }
|
||||
|
||||
.gb-field { margin-bottom:14px; }
|
||||
.gb-field label { display:block; font-size:.82rem; font-weight:700; color:var(--ink-soft); margin-bottom:5px; }
|
||||
.gb-field input, .gb-field textarea, .gb-field select {
|
||||
width:100%; font:inherit; padding:9px 12px; border:1px solid rgba(99,102,241,.22); border-radius:10px; outline:none; color:var(--ink); box-sizing:border-box; transition:.15s;
|
||||
}
|
||||
.gb-field input:focus, .gb-field textarea:focus, .gb-field select:focus { border-color:var(--g1); box-shadow:0 0 0 3px rgba(99,102,241,.14); }
|
||||
.gb-field .hint { font-size:.75rem; color:var(--ink-faint); margin-top:4px; font-family:'Cambria Math',serif; }
|
||||
.gb-row2 { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.gb-row2 > * { flex:1; min-width:140px; }
|
||||
.gb-check { display:flex; align-items:center; gap:8px; font-size:.85rem; font-weight:600; color:var(--ink-soft); }
|
||||
.gb-check input { width:auto; }
|
||||
|
||||
.gb-rows { display:flex; flex-direction:column; gap:7px; }
|
||||
.gb-rrow { display:flex; gap:7px; align-items:center; }
|
||||
.gb-rrow input { font:inherit; padding:7px 10px; border:1px solid rgba(99,102,241,.2); border-radius:9px; outline:none; min-width:0; }
|
||||
.gb-rrow .nm { width:64px; flex:0 0 auto; font-family:'Cambria Math',serif; }
|
||||
.gb-rrow .num { width:60px; flex:0 0 auto; }
|
||||
.gb-rrow .grow { flex:1; font-family:'Cambria Math',serif; }
|
||||
.gb-add { font:inherit; font-size:.82rem; font-weight:700; cursor:pointer; color:var(--accent-ink); background:rgba(99,102,241,.08); border:1px dashed rgba(99,102,241,.3); border-radius:9px; padding:6px 12px; margin-top:7px; }
|
||||
.gb-add:hover { background:var(--accent-soft); }
|
||||
|
||||
.gb-btn { font:inherit; font-weight:700; cursor:pointer; border:none; border-radius:12px; padding:11px 20px; transition:.16s var(--ease); display:inline-flex; align-items:center; gap:7px; }
|
||||
.gb-btn .ic { width:16px; height:16px; }
|
||||
.gb-primary { color:#fff; background:linear-gradient(135deg,var(--g1),var(--g2)); box-shadow:0 8px 20px rgba(99,102,241,.3); }
|
||||
.gb-primary:hover { transform:translateY(-2px); }
|
||||
.gb-ghost { background:rgba(99,102,241,.08); color:var(--accent-ink); }
|
||||
.gb-ghost:hover { background:rgba(99,102,241,.16); }
|
||||
.gb-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
|
||||
|
||||
.gb-preview { margin-top:18px; padding:18px; border-radius:14px; background:linear-gradient(180deg,#fbfbff,#f4f5fd); border:1px solid rgba(99,102,241,.14); }
|
||||
.gb-preview h3 { font-size:.74rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent-ink); font-weight:800; margin:0 0 10px; }
|
||||
.gb-pv-eq { font-family:'Cambria Math',serif; font-size:1.5rem; color:var(--ink); text-align:center; padding:6px 0 14px; }
|
||||
.gb-pv-ans { text-align:center; color:var(--ok-ink); font-weight:700; margin-bottom:10px; }
|
||||
.gb-pv-step { padding:7px 0; border-top:1px dashed rgba(99,102,241,.2); font-size:.9rem; color:#334155; }
|
||||
.gb-pv-step:first-child { border-top:none; }
|
||||
.gb-pv-step .stx { font-family:'Cambria Math',serif; display:block; margin-top:3px; }
|
||||
.gb-err { background:#fee2e2; color:#b91c1c; border-radius:10px; padding:10px 14px; font-size:.86rem; font-weight:600; margin-top:14px; }
|
||||
.gb-err:empty { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<main class="sb-content">
|
||||
<div class="gb-wrap">
|
||||
<h1 class="gb-h1">Конструктор генераторов</h1>
|
||||
<div class="gb-sub">Создайте параметрический генератор задач: диапазоны → формулы → шаблон → ответ. Сервер проверит, что ответ согласован с условием.</div>
|
||||
|
||||
<div class="gb-grid">
|
||||
<div class="gb-card">
|
||||
<h2>Мои генераторы</h2>
|
||||
<div id="gb-list"><div class="gb-empty">Загрузка…</div></div>
|
||||
<button class="gb-btn gb-ghost" id="gb-new" type="button" style="margin-top:8px;width:100%;justify-content:center">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Новый генератор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gb-card">
|
||||
<h2 id="gb-form-title">Новый генератор</h2>
|
||||
|
||||
<div class="gb-field"><label>Заголовок</label><input id="f-title" placeholder="напр. Линейное: ax + b = c"/></div>
|
||||
<div class="gb-row2">
|
||||
<div class="gb-field"><label>Тема</label><input id="f-topic" placeholder="custom" value="custom"/></div>
|
||||
<div class="gb-field"><label>Тип</label>
|
||||
<select id="f-kind">
|
||||
<option value="solve">Уравнение (solve)</option>
|
||||
<option value="compute">Вычисление (compute)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gb-field">
|
||||
<label>Параметры (диапазоны целых)</label>
|
||||
<div class="gb-rows" id="f-pick"></div>
|
||||
<button class="gb-add" id="add-pick" type="button">+ параметр</button>
|
||||
<div class="hint">имя, от, до — напр. a: 2…9. Зарезервированы: x, e, pi, tau.</div>
|
||||
</div>
|
||||
|
||||
<div class="gb-field">
|
||||
<label>Производные (формулы от параметров)</label>
|
||||
<div class="gb-rows" id="f-derive"></div>
|
||||
<button class="gb-add" id="add-derive" type="button">+ формула</button>
|
||||
<div class="hint">напр. c = a*root + b. Приём «корень-вперёд»: задайте root и выведите c.</div>
|
||||
</div>
|
||||
|
||||
<div class="gb-row2">
|
||||
<div class="gb-field"><label>Левая часть</label><input id="f-lhs" placeholder="{a}*x + {b}"/></div>
|
||||
<div class="gb-field"><label>Правая часть</label><input id="f-rhs" placeholder="{c}"/></div>
|
||||
</div>
|
||||
<div class="gb-field"><label>Условие текстом (для «вычисления»)</label><input id="f-display" placeholder="напр. Найдите {p}% от {a}"/></div>
|
||||
|
||||
<div class="gb-row2">
|
||||
<div class="gb-field"><label>Ответ (формула)</label><input id="f-answer" placeholder="root"/></div>
|
||||
<div class="gb-field"><label>Ограничения (опц.)</label><input id="f-require" placeholder="root != 0"/></div>
|
||||
</div>
|
||||
<div class="gb-field"><label class="gb-check"><input type="checkbox" id="f-int" checked/> Ответ — целое число</label></div>
|
||||
|
||||
<div class="gb-field">
|
||||
<label>Шаги решения</label>
|
||||
<div class="gb-rows" id="f-sol"></div>
|
||||
<button class="gb-add" id="add-sol" type="button">+ шаг</button>
|
||||
<div class="hint">пояснение словами + формула шага (одно равенство), напр. x = {cmb} / {a}.</div>
|
||||
</div>
|
||||
|
||||
<div class="gb-actions">
|
||||
<button class="gb-btn gb-ghost" id="gb-preview" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Превью
|
||||
</button>
|
||||
<button class="gb-btn gb-primary" id="gb-save" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||
Сохранить
|
||||
</button>
|
||||
<label class="gb-check" style="margin-left:auto"><input type="checkbox" id="f-pub"/> Опубликовать ученикам</label>
|
||||
</div>
|
||||
|
||||
<div class="gb-err" id="gb-err"></div>
|
||||
<div class="gb-preview" id="gb-pv" style="display:none">
|
||||
<h3>Превью задачи</h3>
|
||||
<div class="gb-pv-eq" id="pv-eq"></div>
|
||||
<div class="gb-pv-ans" id="pv-ans"></div>
|
||||
<div id="pv-sol"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/trainer/_trainer_engine.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
if (typeof LS === 'undefined') return;
|
||||
var ip = LS.initPage();
|
||||
if (!ip) return;
|
||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||
|
||||
var TE = window.TrainerEngine;
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function kat(latex, disp) { if (window.katex && latex) { try { return window.katex.renderToString(latex, { displayMode: !!disp, throwOnError: false }); } catch (e) {} } return null; }
|
||||
|
||||
var editingId = null;
|
||||
|
||||
// ── динамические строки ──
|
||||
function pickRow(name, lo, hi) {
|
||||
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||
d.innerHTML = '<input class="nm" placeholder="имя" value="' + esc(name || '') + '"/>' +
|
||||
'<input class="num" type="number" placeholder="от" value="' + (lo == null ? '' : lo) + '"/>' +
|
||||
'<input class="num" type="number" placeholder="до" value="' + (hi == null ? '' : hi) + '"/>' +
|
||||
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||
return d;
|
||||
}
|
||||
function kvRow(name, val, nmPh, valPh) {
|
||||
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||
d.innerHTML = '<input class="nm" placeholder="' + nmPh + '" value="' + esc(name || '') + '"/>' +
|
||||
'<input class="grow" placeholder="' + valPh + '" value="' + esc(val || '') + '"/>' +
|
||||
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||
return d;
|
||||
}
|
||||
function solRow(note, tex) {
|
||||
var d = document.createElement('div'); d.className = 'gb-rrow';
|
||||
d.innerHTML = '<input class="grow" placeholder="пояснение" value="' + esc(note || '') + '"/>' +
|
||||
'<input class="grow" placeholder="формула шага" value="' + esc(tex || '') + '" style="font-family:\'Cambria Math\',serif"/>' +
|
||||
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
|
||||
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
|
||||
return d;
|
||||
}
|
||||
$('add-pick').addEventListener('click', function () { $('f-pick').appendChild(pickRow()); });
|
||||
$('add-derive').addEventListener('click', function () { $('f-derive').appendChild(kvRow('', '', 'имя', 'формула')); });
|
||||
$('add-sol').addEventListener('click', function () { $('f-sol').appendChild(solRow()); });
|
||||
|
||||
function clearForm() {
|
||||
editingId = null;
|
||||
$('gb-form-title').textContent = 'Новый генератор';
|
||||
$('f-title').value = ''; $('f-topic').value = 'custom'; $('f-kind').value = 'solve';
|
||||
$('f-lhs').value = ''; $('f-rhs').value = ''; $('f-display').value = '';
|
||||
$('f-answer').value = ''; $('f-require').value = ''; $('f-int').checked = true; $('f-pub').checked = false;
|
||||
$('f-pick').innerHTML = ''; $('f-derive').innerHTML = ''; $('f-sol').innerHTML = '';
|
||||
$('f-pick').appendChild(pickRow('a', 2, 9));
|
||||
$('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
|
||||
$('f-sol').appendChild(solRow());
|
||||
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
|
||||
}
|
||||
|
||||
function readRows(container, mapper) {
|
||||
var out = [];
|
||||
container.querySelectorAll('.gb-rrow').forEach(function (r) {
|
||||
var inputs = r.querySelectorAll('input');
|
||||
var v = mapper(inputs);
|
||||
if (v) out.push(v);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function buildSpec() {
|
||||
var spec = { title: $('f-title').value.trim(), topic: $('f-topic').value.trim() || 'custom', kind: $('f-kind').value };
|
||||
var pick = {};
|
||||
readRows($('f-pick'), function (i) {
|
||||
var nm = i[0].value.trim(); if (!nm) return null;
|
||||
var lo = parseInt(i[1].value, 10), hi = parseInt(i[2].value, 10);
|
||||
if (!isNaN(lo) && !isNaN(hi)) pick[nm] = [lo, hi];
|
||||
return null;
|
||||
});
|
||||
spec.pick = pick;
|
||||
var derive = {};
|
||||
readRows($('f-derive'), function (i) { var nm = i[0].value.trim(); if (nm && i[1].value.trim()) derive[nm] = i[1].value.trim(); return null; });
|
||||
if (Object.keys(derive).length) spec.derive = derive;
|
||||
if ($('f-lhs').value.trim()) spec.lhs = $('f-lhs').value.trim();
|
||||
if ($('f-rhs').value.trim()) spec.rhs = $('f-rhs').value.trim();
|
||||
if ($('f-display').value.trim()) spec.display = $('f-display').value.trim();
|
||||
if ($('f-answer').value.trim()) spec.answer = $('f-answer').value.trim();
|
||||
if ($('f-require').value.trim()) spec.require = $('f-require').value.trim();
|
||||
spec.integerAnswer = $('f-int').checked;
|
||||
spec.solution = readRows($('f-sol'), function (i) {
|
||||
var note = i[0].value.trim(), tex = i[1].value.trim();
|
||||
if (!note && !tex) return null;
|
||||
return { note: note, tex: tex };
|
||||
});
|
||||
return spec;
|
||||
}
|
||||
|
||||
// материализуем спек локально (тот же движок, что у ученика) для превью/валидации
|
||||
function tryInstantiate(spec) {
|
||||
if (!spec.title) return { err: 'Укажите заголовок.' };
|
||||
if (!Object.keys(spec.pick || {}).length) return { err: 'Добавьте хотя бы один параметр.' };
|
||||
try {
|
||||
var p = TE.instantiate(spec, { seed: (Math.random() * 1e9) | 0, strict: true });
|
||||
if (!p) return { err: 'Не удалось сгенерировать задачу — проверьте диапазоны и ограничения.' };
|
||||
return { p: p };
|
||||
} catch (e) {
|
||||
return { err: 'Проверка не прошла: ' + (e && e.message ? e.message : 'ответ не согласован с условием') };
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
var r = tryInstantiate(buildSpec());
|
||||
if (r.err) { $('gb-err').textContent = r.err; $('gb-pv').style.display = 'none'; return false; }
|
||||
$('gb-err').textContent = '';
|
||||
var p = r.p;
|
||||
var eq = $('pv-eq'); var h = kat(p.latex, true); if (h) eq.innerHTML = h; else eq.textContent = p.display || '—';
|
||||
$('pv-ans').textContent = 'Ответ: ' + (p.answers ? p.answers.join('; ') : ('x = ' + p.answer));
|
||||
$('pv-sol').innerHTML = (p.solution || []).map(function (s, i) {
|
||||
var m = s.latex ? (kat(s.latex, false) || esc(s.tex || '')) : esc(s.tex || '');
|
||||
return '<div class="gb-pv-step">' + (i + 1) + '. ' + esc(s.note || '') + (m ? '<span class="stx">' + m + '</span>' : '') + '</div>';
|
||||
}).join('');
|
||||
$('gb-pv').style.display = 'block';
|
||||
return true;
|
||||
}
|
||||
$('gb-preview').addEventListener('click', renderPreview);
|
||||
|
||||
function save() {
|
||||
var spec = buildSpec();
|
||||
var r = tryInstantiate(spec);
|
||||
if (r.err) { $('gb-err').textContent = r.err; return; }
|
||||
var status = $('f-pub').checked ? 'published' : 'draft';
|
||||
var pr = editingId ? LS.practiceGenUpdate(editingId, spec, status) : LS.practiceGenCreate(spec, status);
|
||||
pr.then(function (res) {
|
||||
if (res && res.ok) { if (LS.toast) LS.toast(editingId ? 'Генератор обновлён' : 'Генератор создан', 'success'); editingId = res.generator.dbid; $('gb-form-title').textContent = 'Редактирование'; loadList(); }
|
||||
else { $('gb-err').textContent = 'Не удалось сохранить.'; }
|
||||
}).catch(function (e) { $('gb-err').textContent = 'Ошибка сохранения: ' + (e && e.message || ''); });
|
||||
}
|
||||
$('gb-save').addEventListener('click', save);
|
||||
$('gb-new').addEventListener('click', clearForm);
|
||||
|
||||
function fillForm(g) {
|
||||
editingId = g.dbid;
|
||||
$('gb-form-title').textContent = 'Редактирование: ' + (g.title || '');
|
||||
$('f-title').value = g.title || ''; $('f-topic').value = g.topic || 'custom';
|
||||
$('f-kind').value = (g.kind === 'compute') ? 'compute' : 'solve';
|
||||
$('f-lhs').value = g.lhs || ''; $('f-rhs').value = g.rhs || ''; $('f-display').value = g.display || '';
|
||||
$('f-answer').value = g.answer || ''; $('f-require').value = g.require || ''; $('f-int').checked = !!g.integerAnswer;
|
||||
$('f-pub').checked = g.status === 'published';
|
||||
$('f-pick').innerHTML = ''; var pk = g.pick || {};
|
||||
Object.keys(pk).forEach(function (k) { $('f-pick').appendChild(pickRow(k, pk[k][0], pk[k][1])); });
|
||||
if (!Object.keys(pk).length) $('f-pick').appendChild(pickRow());
|
||||
$('f-derive').innerHTML = ''; var dv = g.derive || {};
|
||||
Object.keys(dv).forEach(function (k) { $('f-derive').appendChild(kvRow(k, dv[k], 'имя', 'формула')); });
|
||||
if (!Object.keys(dv).length) $('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
|
||||
$('f-sol').innerHTML = ''; (g.solution || []).forEach(function (s) { $('f-sol').appendChild(solRow(s.note, s.tex)); });
|
||||
if (!(g.solution || []).length) $('f-sol').appendChild(solRow());
|
||||
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
var myId = (LS.getUser && LS.getUser()) ? LS.getUser().id : null;
|
||||
function loadList() {
|
||||
LS.practiceGenList().then(function (r) {
|
||||
var mine = (r.generators || []).filter(function (g) { return g.owner_id === myId; });
|
||||
var box = $('gb-list');
|
||||
if (!mine.length) { box.innerHTML = '<div class="gb-empty">Пока нет генераторов. Создайте первый.</div>'; return; }
|
||||
box.innerHTML = '';
|
||||
mine.forEach(function (g) {
|
||||
var d = document.createElement('div'); d.className = 'gb-list-item';
|
||||
d.innerHTML = '<div class="gb-li-main"><div class="gb-li-title">' + esc(g.title) + '</div><div class="gb-li-meta">' + esc(g.topic) + ' · ' + esc(g.kind || 'solve') + '</div></div>' +
|
||||
'<span class="gb-li-pub ' + (g.status === 'published' ? 'published' : 'draft') + '">' + (g.status === 'published' ? 'опубл.' : 'черновик') + '</span>' +
|
||||
'<button class="gb-icon-btn gb-del" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button>';
|
||||
d.querySelector('.gb-li-main').addEventListener('click', function () { fillForm(g); });
|
||||
d.querySelector('.gb-del').addEventListener('click', function () {
|
||||
LS.practiceGenDelete(g.dbid).then(function () { if (LS.toast) LS.toast('Удалено', 'success'); if (editingId === g.dbid) clearForm(); loadList(); }).catch(function () {});
|
||||
});
|
||||
box.appendChild(d);
|
||||
});
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}).catch(function () { $('gb-list').innerHTML = '<div class="gb-empty">Не удалось загрузить.</div>'; });
|
||||
}
|
||||
|
||||
clearForm();
|
||||
loadList();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+17
-5
@@ -428,8 +428,12 @@
|
||||
|
||||
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
|
||||
var isTeacher = !!(ip && ip.isTeacher);
|
||||
var customGens = []; // пользовательские генераторы (P13), тема «Мои генераторы»
|
||||
function skillKey(g) { return g.skill || g.id; }
|
||||
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
|
||||
function skillsOf(topicKey) {
|
||||
if (topicKey === 'custom') return customGens;
|
||||
return TG.byTopic ? TG.byTopic(topicKey) : gens;
|
||||
}
|
||||
function isWord() { return curTopic === 'word'; }
|
||||
function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); }
|
||||
|
||||
@@ -998,10 +1002,18 @@
|
||||
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
|
||||
if (isTeacher) $('tr-analytics-btn').style.display = '';
|
||||
}
|
||||
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
|
||||
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })
|
||||
.catch(function () {})
|
||||
.then(boot);
|
||||
Promise.all([
|
||||
LS.practiceProgressList ? LS.practiceProgressList().catch(function () { return null; }) : Promise.resolve(null),
|
||||
LS.practiceGenList ? LS.practiceGenList().catch(function () { return null; }) : Promise.resolve(null)
|
||||
]).then(function (res) {
|
||||
var pr = res[0], cgr = res[1];
|
||||
if (pr && pr.progress) pr.progress.forEach(function (row) { prog[row.skill] = row; });
|
||||
if (cgr && cgr.generators && cgr.generators.length) {
|
||||
customGens = cgr.generators;
|
||||
topics.push({ key: 'custom', label: 'Мои генераторы', custom: true });
|
||||
}
|
||||
boot();
|
||||
}).catch(boot);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1185,6 +1185,7 @@ window.LS = {
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
gameProgressList, gameProgressSubmit,
|
||||
practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats, practiceAuthor, practiceAssign,
|
||||
practiceGenList, practiceGenGet, practiceGenCreate, practiceGenUpdate, practiceGenDelete,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
@@ -1427,6 +1428,11 @@ async function practiceGenerate(topic) { return req('POST', '/practice/genera
|
||||
async function practiceClassStats(classId) { return req('GET', '/practice/class-stats?class_id=' + encodeURIComponent(classId)); }
|
||||
async function practiceAuthor(data) { return req('POST', '/practice/author', data); }
|
||||
async function practiceAssign(classId, topic, title) { return req('POST', '/practice/assign', { class_id: classId, topic: topic || 'word-linear', title }); }
|
||||
async function practiceGenList() { return req('GET', '/practice/generators'); }
|
||||
async function practiceGenGet(id) { return req('GET', '/practice/generators/' + id); }
|
||||
async function practiceGenCreate(spec, status) { return req('POST', '/practice/generators', { spec, status }); }
|
||||
async function practiceGenUpdate(id, spec, status) { return req('PUT', '/practice/generators/' + id, { spec, status }); }
|
||||
async function practiceGenDelete(id) { return req('DELETE', '/practice/generators/' + id); }
|
||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
${G('practice', 'Практика и игры', `
|
||||
${L('/lab', 'atom', 'Лаборатория')}
|
||||
${L('/trainer', 'dumbbell', 'Тренажёр')}
|
||||
${L('/trainer-builder', 'wand-2', 'Конструктор задач', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
${L('/quantik', 'rocket', 'Квантик: Законы Мира')}
|
||||
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
${L('/biochem', 'flask-conical', 'Биохимия')}
|
||||
|
||||
@@ -82,8 +82,19 @@ solved-форме `x=c` → общий `onSolved` (засчитывается к
|
||||
прогресс трекается; учитель видит выполнение и результаты; интеграция с journal/homework.
|
||||
- Апгрейд текущего `assign` (уведомление) до отслеживаемого задания (таблица).
|
||||
|
||||
## P13 — Конструктор генераторов + управление пулом
|
||||
Учитель создаёт ПАРАМЕТРИЧЕСКИЕ генераторы (не только одиночные задачи).
|
||||
## P13 — Конструктор генераторов + управление пулом — DONE (частично)
|
||||
**Сделано:** таблица `custom_generators` (мигр.**084**, spec_json + status draft/published),
|
||||
`customGeneratorController` (`validateGenSpec` без исполнения — лимиты/типы; CRUD,
|
||||
own+published, per-row ownership), роуты `/api/practice/generators[/:id]`, клиент
|
||||
`LS.practiceGen*`. **Страница-конструктор** `/trainer-builder` (учитель/админ): форма
|
||||
(заголовок/тема/тип/диапазоны pick/формулы derive/lhs/rhs/display/ответ/решение) +
|
||||
**живое превью** (тот же `TE.instantiate(strict)` материализует и проверяет ответ
|
||||
подстановкой) + список своих с правкой/удалением/публикацией. Тренажёр грузит свои+
|
||||
опубликованные генераторы в тему **«Мои генераторы»** (пошаговый режим работает и для
|
||||
них). Пункт сайдбара `/trainer-builder` (teacher-only). Тесты `custom-generators.test.js`
|
||||
12/12; смоук движка T17 (кастомный спек + strict-валидация). **Осталось (стретч):**
|
||||
форма для kind roots/simplify/inequality (движок их поддерживает), управление пулом
|
||||
LLM-задач (P3) из UI, генерация по теме урока.
|
||||
- Визуальный билдер: диапазоны `pick`, формулы `derive`, шаблоны `lhs/rhs`, ответ,
|
||||
шаги решения + live-превью + валидация (отложенный «полный P4»).
|
||||
- Управление пулом (ревью/правка/удаление), генерация по теме урока/§ учебника.
|
||||
|
||||
Reference in New Issue
Block a user