From aa20892a7944cb479fef3cad7790b68aaf3cb8b8 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 15:38:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20=C2=AB=D1=81=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0-=D0=B3=D0=B5=D1=80=D0=BE=D0=B9=C2=BB=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=20+=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE=D1=80=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - мощный визуал: уравнение на яркой градиентной «сцене» (бело на индиго→фиолет, текстура-клетка), рабочая зона снизу на белом; верный ответ заливает сцену изумрудом, неверный — красным (с pop/shake) - конструктор генераторов — ТОЛЬКО админ: страница /trainer-builder гейт ip.isAdmin; роуты POST/PUT/DELETE /generators → requireRole(admin); сайдбар-пункт hidden:!isAdm - выделен отдельным цветом: янтарная кнопка «Конструктор» в баре режима (только админ) → /trainer-builder - тема пользовательских генераторов: «Мои генераторы» для админа / «Авторские» для остальных (видят published) - тесты custom-generators 13/13 (админ создаёт; учитель/ученик 403); страница-смоук 33/33; эмодзи/eval 0 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/practice.js | 9 ++-- backend/tests/custom-generators.test.js | 38 ++++++++------- frontend/trainer-builder.html | 2 +- frontend/trainer.html | 64 ++++++++++++++++++------- js/sidebar.js | 2 +- 5 files changed, 76 insertions(+), 39 deletions(-) diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index 4ce1c53..f00dfbc 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -22,12 +22,13 @@ router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass); // Аналитика класса — только учитель/админ (владение проверяется в хендлере). router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats); -// Конструктор генераторов (P13): чтение — own+published; мутации — учитель/админ + ownership. +// Конструктор генераторов (P13): чтение — own+published (ученики видят published); +// СОЗДАНИЕ/правка — ТОЛЬКО админ (конструктор — админский инструмент). const cg = require('../controllers/customGeneratorController'); router.get('/generators', cg.genList); -router.post('/generators', requireRole('teacher', 'admin'), cg.genCreate); +router.post('/generators', requireRole('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); +router.put('/generators/:id', requireRole('admin'), cg.genUpdate); +router.delete('/generators/:id', requireRole('admin'), cg.genDelete); module.exports = router; diff --git a/backend/tests/custom-generators.test.js b/backend/tests/custom-generators.test.js index 2083d94..6de9e0e 100644 --- a/backend/tests/custom-generators.test.js +++ b/backend/tests/custom-generators.test.js @@ -41,60 +41,66 @@ describe('validateGenSpec', () => { }); }); -describe('/api/practice/generators CRUD', () => { - let teacher, other, student, gid; +describe('/api/practice/generators CRUD (конструктор — только админ)', () => { + let admin, other, teacher, student, gid; before(async () => { + admin = (await getToken('admin')).token; + other = (await getToken('admin')).token; teacher = (await getToken('teacher')).token; - other = (await getToken('teacher')).token; student = (await getToken('student')).token; }); - it('учитель создаёт генератор', async () => { - const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher); + it('админ создаёт генератор', async () => { + const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, admin); assert.equal(res.status, 200, `got ${res.status}`); assert.equal(res.body.ok, true); assert.ok(/^cg\d+$/.test(res.body.generator.id), 'id вида cg'); gid = res.body.generator.dbid; }); + it('учителю создавать запрещено (403)', async () => { + const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher); + assert.equal(res.status, 403, `got ${res.status}`); + }); + it('ученику создавать запрещено (403)', async () => { const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, student); assert.equal(res.status, 403, `got ${res.status}`); }); it('невалидный спек → 400', async () => { - const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, teacher); + const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, admin); assert.equal(res.status, 400, `got ${res.status}`); }); it('автор видит свой генератор в списке', async () => { - const res = await inject('GET', '/api/practice/generators', null, teacher); + const res = await inject('GET', '/api/practice/generators', null, admin); assert.equal(res.status, 200); assert.ok(res.body.generators.some(g => g.dbid === gid), 'свой генератор в списке'); }); - it('чужой draft не виден другому учителю', async () => { + 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); + it('учителю изменять запрещено (403, роль)', async () => { + const res = await inject('PUT', '/api/practice/generators/' + gid, { spec: SPEC }, teacher); assert.equal(res.status, 403, `got ${res.status}`); }); - it('публикация делает генератор видимым другим', async () => { - const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, teacher); + it('публикация делает генератор видимым другим (и ученику)', async () => { + const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, admin); assert.equal(pub.status, 200); assert.equal(pub.body.generator.status, 'published'); - const res = await inject('GET', '/api/practice/generators', null, other); - assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден другому'); + const res = await inject('GET', '/api/practice/generators', null, student); + assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден ученику'); }); it('автор удаляет свой генератор', async () => { - const res = await inject('DELETE', '/api/practice/generators/' + gid, null, teacher); + const res = await inject('DELETE', '/api/practice/generators/' + gid, null, admin); assert.equal(res.status, 200); - const after = await inject('GET', '/api/practice/generators/' + gid, null, teacher); + const after = await inject('GET', '/api/practice/generators/' + gid, null, admin); assert.equal(after.status, 404, 'после удаления 404'); }); }); diff --git a/frontend/trainer-builder.html b/frontend/trainer-builder.html index c0b9545..73d4eb2 100644 --- a/frontend/trainer-builder.html +++ b/frontend/trainer-builder.html @@ -190,7 +190,7 @@ if (typeof LS === 'undefined') return; var ip = LS.initPage(); if (!ip) return; - if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; } + if (!ip.isAdmin) { location.href = '/dashboard'; return; } // конструктор — только админам var TE = window.TrainerEngine; var $ = function (id) { return document.getElementById(id); }; diff --git a/frontend/trainer.html b/frontend/trainer.html index fd563ff..fb071b2 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -68,25 +68,41 @@ /* ── карточка задачи (герой) ── */ .tr-card { position: relative; overflow: hidden; background: var(--card); - border: 1px solid rgba(99,102,241,.10); border-radius: var(--r-lg); - padding: 34px 30px 30px; box-shadow: var(--sh); transition: box-shadow .3s var(--ease), transform .3s var(--ease); + border: 1px solid rgba(99,102,241,.12); border-radius: var(--r-lg); + box-shadow: var(--sh); transition: box-shadow .3s var(--ease), transform .3s var(--ease); } - .tr-card::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 5px; background: linear-gradient(90deg, var(--g1), var(--g2)); } - .tr-card.tr-correct { box-shadow: 0 20px 54px rgba(16,185,129,.24); animation: trPop .5s var(--ease); } - .tr-card.tr-correct::before { height: 6px; background: linear-gradient(90deg, var(--ok), #34d399); } + .tr-card.tr-correct { box-shadow: 0 22px 56px rgba(16,185,129,.30); animation: trPop .5s var(--ease); } .tr-card.tr-wrong { animation: trShake .42s var(--ease); } - .tr-card.tr-wrong::before { background: linear-gradient(90deg, var(--bad), #fb7185); } @keyframes trPop { 0% { transform: scale(1); } 32% { transform: scale(1.014); } 100% { transform: scale(1); } } @keyframes trShake { 0%,100% { transform: translateX(0); } 18% { transform: translateX(-7px); } 38% { transform: translateX(6px); } 58% { transform: translateX(-4px); } 78% { transform: translateX(2px); } } + /* ── «сцена»-герой: уравнение крупно на ярком градиенте ── */ + .tr-stage { + position: relative; overflow: hidden; text-align: center; color: #fff; + padding: 32px 28px 36px; transition: background .35s var(--ease); + background: linear-gradient(135deg, #4f46e5 0%, #6d3aed 52%, #8b5cf6 100%); + } + .tr-stage::before { + content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .55; + background-image: + radial-gradient(440px 220px at 80% -25%, rgba(255,255,255,.20), transparent 60%), + linear-gradient(rgba(255,255,255,.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.07) 1px, transparent 1px); + background-size: 100% 100%, 22px 22px, 22px 22px; + } + .tr-stage > * { position: relative; } + .tr-card.tr-correct .tr-stage { background: linear-gradient(135deg, #059669, #10b981 58%, #34d399); } + .tr-card.tr-wrong .tr-stage { background: linear-gradient(135deg, #dc2626, #ef4444 58%, #fb7185); } + .tr-work { padding: 24px 26px 28px; } + #tr-skill { - color: var(--accent-ink); font-family: 'Manrope', sans-serif; font-size: .74rem; font-weight: 800; - text-transform: uppercase; letter-spacing: .07em; margin-bottom: 16px; + color: rgba(255,255,255,.75); font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 800; + text-transform: uppercase; letter-spacing: .1em; margin-bottom: 14px; } .tr-eq { font-family: 'Cambria Math', 'Times New Roman', Georgia, serif; - font-size: clamp(1.8rem, 5.2vw, 2.5rem); font-weight: 600; letter-spacing: .01em; - color: var(--ink); text-align: center; padding: 8px 0 26px; user-select: none; + font-size: clamp(1.9rem, 5.6vw, 2.7rem); font-weight: 600; letter-spacing: .01em; + color: #fff; text-align: center; padding: 2px 0; user-select: none; text-shadow: 0 2px 14px rgba(0,0,0,.18); } .tr-inrow { display: flex; gap: 10px; align-items: stretch; max-width: 440px; margin: 0 auto; } @@ -152,9 +168,9 @@ .tr-step-math { display: block; font-family: 'Cambria Math', serif; font-size: 1.18rem; color: var(--ink); margin-left: 30px; } .tr-step-n { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: linear-gradient(135deg, var(--g1), var(--g2)); color: #fff; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 800; margin-right: 9px; vertical-align: 1px; } .tr-eq .katex-display { margin: 0; } - .tr-eq .katex { font-size: 1.12em; } - /* текстовый prompt (проценты/упрощение) — компактнее уравнения */ - .tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.15rem, 3.4vw, 1.6rem); line-height: 1.45; color: var(--ink); } + .tr-eq .katex { font-size: 1.12em; color: #fff; } + /* текстовый prompt (проценты/упрощение) — компактнее уравнения, на сцене белым */ + .tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.15rem, 3.4vw, 1.6rem); line-height: 1.45; color: #fff; } /* выбор навыка внутри темы */ .tr-skills { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 24px; } @@ -222,6 +238,9 @@ .tr-mode-btn.on { color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--g1), var(--g2)); box-shadow: 0 8px 20px rgba(99,102,241,.32); } .tr-session { font-size: .82rem; font-weight: 800; color: var(--accent-ink); padding: 4px 12px; border-radius: 99px; background: rgba(99,102,241,.1); } .tr-session:empty { display: none; } + /* конструктор — отдельный (янтарный) цвет, только админ */ + .tr-admin-btn { color: #fff; border-color: transparent; background: linear-gradient(135deg, #f59e0b, #f97316); box-shadow: 0 8px 20px rgba(245,158,11,.32); } + .tr-admin-btn:hover { color: #fff; border-color: transparent; background: linear-gradient(135deg, #f59e0b, #f97316); transform: translateY(-1px); } /* ── итог сессии ── */ .tr-summary { @@ -278,6 +297,10 @@ Аналитика класса +