diff --git a/README.md b/README.md index 091d4bc..5bc2e54 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # LearnSpace -**Образовательная платформа с интерактивной онлайн-доской, системой тестирования, управлением классами и элементами геймификации.** +**Образовательная платформа с интерактивной онлайн-доской, системой тестирования, учебниками, виртуальной лабораторией и геймификацией.** -Стек: Node.js · Express · SQLite · Vanilla JS · Canvas API · SSE · WebRTC +Стек: Node.js · Express · SQLite (`node:sqlite` DatabaseSync) · Vanilla JS · Canvas API · SSE · WebRTC --- @@ -15,6 +15,8 @@ - [Архитектура](#архитектура) - [API](#api) - [Роли пользователей](#роли-пользователей) +- [Feature Flags](#feature-flags) +- [Контент](#контент) --- @@ -29,38 +31,60 @@ - Маркер (highlighter) с настраиваемой прозрачностью - Лазерная указка (без сохранения) - Ластик -- 11 фигур: прямоугольник, скруглённый прямоугольник, эллипс, линия, стрелка, треугольник, ромб, шестиугольник, звезда, облако, коннектор -- Стикеры с редактированием -- Текстовые блоки -- Вставка изображений -- Таблицы +- Connector (линии со стрелками) +- Стикеры с редактированием текста +- Текстовые блоки (inline editing) +- Вставка изображений (drag & drop + URL) +- Таблицы (интерактивные) - LaTeX-формулы (KaTeX) с визуальным редактором и категориями символов -- Система координат с построением графиков функций (встроенный парсер) +- Система координат с построением графиков функций (встроенный парсер выражений) - Числовая ось для неравенств (точки, интервалы) -- Циркуль с анимацией +- Циркуль с трёхфазной state machine + +**Фигуры (11):** прямоугольник, скруглённый прямоугольник, эллипс, линия, стрелка, треугольник, ромб, шестиугольник, звезда, облако, коннектор **Инструмент выделения** - Перемещение и изменение размера всех объектов - Вращение объектов (handle над объектом) - Lasso multi-select (резиновая рамка) - Shift+click для добавления к выделению -- Copy / Paste с автосмещением +- Copy/Paste с автосмещением - Snap-гайды при выравнивании объектов +- Delete, Bring to front, Send to back **Навигация по холсту** - Zoom: колесо мыши к курсору, Ctrl+`+`/`-`/`0`, кнопки в тулбаре - Pan: зажатый пробел + перетаскивание -- Minimap (192×108) в правом нижнем углу при zoom > 1 — клик/drag для прыжка по холсту +- Minimap (192×108) в правом нижнем углу при zoom > 1 — клик/drag для прыжка **Инструменты измерения** -- Линейка: поворот (drag ↺), изменение длины (drag ↔), панель свойств (угол, длина) +- Линейка: поворот, изменение длины, панель свойств (угол, длина) - Транспортир: поворот, изменение радиуса, панель свойств - Авто-измерения геометрических фигур (длины, углы, площадь) +**Планиметрия (геометрические построения)** +- Середина отрезка, биссектриса, высота, описанная/вписанная окружность +- Касательная, параллельный перенос, симметрия +- Правильный n-угольник, параллелограмм, средняя линия треугольника +- Метки параллельности, прямых углов, одинаковых отрезков +- Дуги углов, засечки рёбер + +**Стереометрия 3D** +- Куб, прямоугольный параллелепипед, тетраэдр, октаэдр, пирамида, призма +- Усечённая пирамида, правильные многогранники, конус, цилиндр, сфера +- Скрещивающиеся прямые, производные точки 3D, длины рёбер +- Вращение мышью, deep-link на конкретную фигуру (`openSim('stereo:cube')` / `?stereofig=`) + +**Темы доски (4)** +- **Chalkboard** — зелёный фон, меловая текстура +- **Blackboard** — тёмно-синий, диагональная текстура +- **Corkboard** — пробковый, волокна +- **Whiteboard** — светло-серый, маркерная доска + **Страницы и шаблоны** - Неограниченное количество страниц на сессию - Боковая панель с миниатюрами страниц -- Шаблоны: чистая страница, сетка, линейки, точки, координатные оси +- Шаблоны: чистая, сетка, линованная, точки, координатные оси - Экспорт страницы в PNG **Коммуникация** @@ -72,49 +96,203 @@ - Курсор учителя виден ученикам в реальном времени - Выдача прав рисования отдельным ученикам - Личные заметки по уроку (per user) +- Режим аннотации поверх симуляции + +--- + +### Учебники (Textbooks) + +Интерактивные параграф-по-параграфу учебники с прогрессом чтения. + +**Доступный контент (18 учебников)** + +| Предмет | Классы | +|---------|--------| +| Химия | 7, 8, 9 | +| Физика | 7, 8, 9, 10, 11 | +| Алгебра | 7, 8, 9, 10, 11 | +| Геометрия | 7, 8, 9, 10, 11 | + +**Функции:** +- Параграф-по-параграф навигация с прогресс-баром и `last_para` (последнее место) +- Задания на чтение: учитель назначает конкретные §, система проверяет выполнение +- Кнопка «В лабораторию» — ссылки на связанные симуляции (`lab_sim_links`) +- Чип «Связано с программой» (курикулумная привязка) +- Хабы глав: агрегированный прогресс по всем главам учебника +- Закладки с заметками и цветами +- Просмотр прогресса учеников класса (teacher view) +- Прогресс хранится в `textbook_progress` (JSON массив прочитанных §) + +**Контент-движок Химии 7 и 8:** +- 26 параграфов (Химия 7), 52 параграфа (Химия 8) с интерактивными виджетами +- Canvas-анимации (реакции, осадки, горение, электролиз, индикаторы) +- 3D-модели молекул (ball-and-stick, VSEPR-геометрия) +- Интегрированные задания и лабораторные работы +- Карты связей понятий, глоссарий в финалах глав + +--- + +### Виртуальная лаборатория (40 симуляций) + +Canvas-движок без внешних зависимостей (по аналогу three.js — всё сделано вручную). + +**Физика (14):** +projectile, waves, hydrostatics, race, dynamics, isoprocess, pendulum, opticsbench, radioactive, collision, heatengine, circuit, emfield, logic + +**Химия (14):** +titration, bohratom, qualanalysis, crystal, molphys, orbitals, organic, periodic, solutions, stoichiometry, chemsandbox, chemistry, equilibrium, electrolysis + +**Математика (9):** +graph, triangle, quadratic, normaldist, geometry (планиметрия), stereo (стереометрия 3D), probability, graphtransform, trigcircle + +**Биология (2):** +celldivision, photosynthesis + +**Игры (1):** +angrybirds + +**Lab Content Engine (LabRegistry):** +- Все симуляции зарегистрированы в `window.LabRegistry` через data-driven манифест +- Каталог в БД (`lab_sims`): включение/отключение отдельных симуляций, featured, теги +- Ленивая загрузка кода симуляций (Phase 3) +- Связь симуляций с параграфами учебников (`lab_sim_links`) +- Deep-link `?sim=` открывает конкретную симуляцию +- Курикулумная привязка: subject/grade/topics в манифесте +- Управление в админке: включение симуляций, редактор связей с учебниками + +**Оптическая скамья (opticsbench) — режим «Конструктор»:** +- 2D-трассировщик лучей (линза, зеркало, преломление) +- Характеристические лучи предмета, дисперсия, ПВО +- Алиасы deep-link: `thinlens`, `mirrors`, `refraction` + +--- + +### Биохимия (5 страниц) + +Интерактивный модуль без тяжёлых зависимостей (только Canvas). + +**Молекулярный редактор (`biochem.html`):** +- 2D и настоящая 3D-геометрия по VSEPR (ОЭПВО) +- Тумблер δ± — тепловая карта частичных зарядов (синий δ+/красный δ−), стрелка диполя +- Гибридизация, форма молекулы, валентный угол в панели свойств +- Импорт SMILES (учебное подмножество), экспорт PNG/JSON +- Химический движок `BIO` (window.BIO, dual-export browser+Node): `analyze`, `partialCharges`, `dipole`, `polarity`, `functionalGroups`, `balance`, `vsepr`, `render3D`, `parseSmiles`, `valency` +- Расширенная валидация валентности: подсказки («Углерод (C): занято 5 связей, максимум 4 — убери 1») + +**Серверный химический слой (`services/chem.js`):** +- Переиспользует то же ядро `biochem-core.js` (без дублирования) через dual-export +- `POST /api/biochem/analyze` → {formula, mass, dbe, geometry, polarity, dipole, charges, groups, massFractions, valency} +- `/validate` переведён на ядро (единые подсказки валентности на клиенте и сервере) +- `LS.biochemAnalyze(atoms, bonds)` в api.js + +**Библиотека (`biochem-library.html`):** 105+ молекул, 2D/3D-превью, сравнение + +**Реакции (`biochem-reactions.html`):** 27 реакций, `BIO.balance` (Гаусс+НОК), энергодиаграмма (canvas: реагенты→продукты, стрелка ΔH, экзо/эндо), коэффициенты + +**Метаболические пути (`biochem-pathways.html`):** пути из БД (`bio_pathways`), прогресс Learn-режима, награда XP + +**Свойства (`biochem-properties.html`):** сравнение молекул, столбчатый график молярных масс, экспорт CSV + +--- + +### Управление классом + +- Создание классов, добавление учеников +- Задания с дедлайнами и прикреплёнными файлами +- Отслеживание сдачи: статусы new/reviewed/accepted/revision +- Текстовые задания с прикреплением файлов учеником +- Журнал оценок +- Объявления и лента активности (Google Classroom-стиль) +- Шаблоны заданий для переиспользования +- Live-викторины в реальном времени (SSE) +- Аналитика успеваемости +- Назначение ученикам без класса (teacherStudents) +- Назначение чтения конкретных § учебника как домашнего задания + +--- ### Учебные материалы -- Банк вопросов с уровнями сложности и тематиками +- Банк вопросов с уровнями сложности, тематиками, поддержкой HTML/KaTeX - Конструктор тестов с перемешиванием вопросов - Многошаговые уроки с блоками: текст, медиа, формулы, код, викторина - Курсы с прогрессом прохождения - Карточки (flashcards) со spaced repetition - Граф знаний — визуализация связей между темами -- Интерактивные лабораторные работы (30+ симуляций): физика, химия, биология, математика +- Сборники ЦТ/ЦЭ: физика 2019–2024, математика 2021–2024 (300+ вопросов) +- Экзаменационные тесты (exam9): 80 вариантов по математике 9 класса -### Управление классом - -- Создание классов, добавление учеников -- Задания с дедлайнами, отслеживание сдачи -- Журнал оценок -- Объявления и лента активности (Google Classroom-стиль) -- Шаблоны заданий для переиспользования -- Live-викторины в реальном времени -- Аналитика успеваемости +--- ### Специализированный контент -- **Биохимия**: интерактивные молекулы, реакции, метаболические пути, электрофорез -- **Красная книга**: виды, биомы, экосистемы, пищевые сети, популяционные данные, квесты +**Биохимия** — см. раздел выше + +**Красная книга (4 страницы):** +- Виды, биомы, экосистемы, пищевые сети +- Популяционные данные, квесты + +**Коллекции:** коллекционирование предметов с галереей + +**Galaxy Map (`/sitemap`):** интерактивная Canvas-карта всех модулей платформы с feature flag фильтрацией + +--- ### Геймификация -- Опыт (XP) и уровни -- Система достижений +- Опыт (XP) и уровни (8 уровней эволюции, визуальная модель с VSEPR-геометрией) +- 38+ достижений в 6 группах (onboarding, streak, lab, exam, biochem, leaderboard) - Стрики (серии дней) -- Ежедневные цели и задачи -- Виртуальный питомец -- Магазин с внутренней валютой +- Ежедневные цели (easy/medium/hard тиры) с кольцом прогресса +- Виртуальный питомец: эволюция по уровням, 6 цветов, аксессуары (шляпа, очки, корона), радужный ошейник при streak ≥ 7, автономное настроение +- Магазин с внутренней валютой (монеты), фоны для питомца - Коллекционирование предметов +**Панель администратора геймификации:** +- Статистика: суммарный XP, монеты, средний уровень, достижения, покупки +- Топ-10 по XP, последние начисления XP с читаемыми подписями +- Начисление XP/монет: select с полным списком пользователей + фильтр, пресеты (0/+10/+25/+50/+100/+250), пресеты причин, fix: 0 XP не начисляется +- Сброс прогресса пользователя + +--- + ### Администрирование -- Управление пользователями и ролями -- Гранулярные разрешения (RBAC) -- Feature flags (глобальные и per-class) -- Журнал аудита -- Кабинет родителя +- Управление пользователями и ролями (student, teacher, admin, free_student) +- Гранулярные разрешения (RBAC) — per-role и per-user +- Feature flags: включение/отключение модулей (biochem, textbooks, flashcards, board, live_quiz, exam9) +- Управление симуляциями: каталог в БД, включение/отключение, редактор связей с учебниками +- Доступ к контенту (allowlist): учебники и экзамены по классам и ученикам (`content_access`) +- Журнал аудита (`admin_audit_log`) +- System Health: реальное время метрики (CPU, RAM, event loop lag), HTTP-статистика запросов, тренды (canvas-графики), журнал последних ошибок +- Кабинет родителя с аналитикой по ученику +- Аватары с crop/zoom — ученик загружает, учитель/админ модерирует +- Панель «Обзор» (командный центр): KPI 24ч, лента завершений, триаж событий, распределение по предметам +- KaTeX рендеринг в секции «Вопросы» +- Глобальный поиск (command palette): пользователи, тесты, классы + +--- + +### Дашборд (Главная) + +**Для ученика:** +- Карточка «Продолжить/Начать чтение» с обложкой учебника (цветная, по теме) +- Карточка «Лаборатория дня» с превью симуляции на фоне блока + deep-link +- Карточка питомца с реальными данными из `/api/pet` (имя, модель, цвет, уровень XP, настроение) +- Активность (тепловая карта / streak-календарь), слабые темы, задания + +**Для администратора:** +- Командный центр: pulse KPIs с count-up анимацией, attention inbox, лента завершений, health-плитки контента, топ/антирейтинг дня + +**Шапка (dash-header):** увеличенная (76px), аватарка 46px, Unbounded 1.15rem, кольца ученика 48px, чипы администратора крупные + +--- + +### Профиль и настройки + +- Звуковая система (12 звуков на Web Audio API): достижения, уровень, XP, монеты, тесты, доска +- Настройки предпочтений на сервере (dashboard widget visibility, whiteboard defaults) +- Вкладка звука и настроек в профиле --- @@ -130,9 +308,9 @@ cp backend/.env.example .env docker compose up -d ``` -Платформа будет доступна на `http://localhost:3000`. +Платформа доступна на `http://localhost:3000`. -Первый пользователь с ролью `admin` создаётся через seed: +Первый admin создаётся через seed: ```bash docker compose exec app npm run seed ``` @@ -141,29 +319,24 @@ docker compose exec app npm run seed ## Ручная установка -**Требования:** Node.js 18+ +**Требования:** Node.js 22+ ```bash -# 1. Клонировать и установить зависимости git clone https://git.dolgolyov-family.by/maxim.dolgolyov/Learn_System.git cd Learn_System/backend npm install -# 2. Конфигурация cp .env.example .env # Отредактировать .env -# 3. Миграции и начальные данные -npm run migrate -npm run seed # опционально — тестовые вопросы и пользователи +npm run migrate # применить все миграции (47 SQL-файлов) +npm run seed # опционально — тестовые данные -# 4. Запуск -npm start # production -npm run dev # development (nodemon) +npm start # production +npm run dev # development (nodemon) ``` -Сервер запустится на `http://localhost:3000`. -Фронтенд раздаётся Express-ом из папки `frontend/`. +Сервер стартует на `http://localhost:3000`. Фронтенд раздаётся Express из `frontend/`. --- @@ -189,28 +362,49 @@ npm run dev # development (nodemon) Learn_System/ ├── backend/ │ ├── src/ -│ │ ├── server.js # Express app, 28 route groups +│ │ ├── server.js # Express app, 40 route groups │ │ ├── config.js -│ │ ├── sse.js # Server-Sent Events broadcast -│ │ ├── controllers/ # 30 контроллеров -│ │ ├── routes/ # 28 файлов маршрутов -│ │ ├── middleware/ # auth, RBAC, rate limit, validate +│ │ ├── sse.js # Server-Sent Events broadcast +│ │ ├── controllers/ # 40+ контроллеров +│ │ │ ├── gamification/ # _shared, service, admin, achievements (split) +│ │ │ ├── classroom/ # 7 domain-файлов (split) +│ │ │ └── biochemController.js +│ │ ├── routes/ # 40 файлов маршрутов +│ │ ├── services/ +│ │ │ ├── chem.js # Серверный химический движок (dual-export с BIO) +│ │ │ └── contentAccess.js # Allowlist учебников и экзаменов +│ │ ├── middleware/ # auth, RBAC, rate limit, validate │ │ ├── db/ -│ │ │ ├── migrate.js # Auto-migration при старте (76 таблиц) -│ │ │ ├── db.js # better-sqlite3 singleton -│ │ │ └── migrations/ # SQL-файлы схемы -│ │ └── utils/ +│ │ │ ├── migrations-runner.js # Версионированный runner (47 миграций) +│ │ │ ├── db.js # node:sqlite DatabaseSync singleton +│ │ │ └── migrations/ # SQL-файлы схемы (000–046) +│ │ └── utils/ # audit, sanitize, healthMonitor │ └── package.json ├── frontend/ -│ ├── *.html # 43 страницы -│ ├── css/ls.css # Общая дизайн-система +│ ├── *.html # 60 страниц +│ ├── css/ls.css # Общая дизайн-система │ └── js/ -│ ├── whiteboard.js # Движок доски (~3200 строк) -│ ├── classroom-rtc.js # WebRTC модуль -│ └── labs/ # 30+ физических симуляций +│ ├── whiteboard.js # Движок доски (~3500+ строк) +│ ├── classroom-rtc.js # WebRTC модуль +│ ├── biochem-core.js # Химическое ядро BIO (dual-export) +│ ├── pet-sprite.js # Рендерер питомца (dual-export, shared) +│ ├── lab-previews.js # SVG-превью симуляций для дашборда +│ ├── labs/ # 40 симуляций + LabRegistry +│ │ ├── _registry.js # LabRegistry — единый реестр +│ │ ├── _register-all.js # Data-driven регистрация всех симуляций +│ │ ├── lab-glue.js # Каталог SIMS, THEORY, preview SVG +│ │ ├── lab-init.js # openSim dispatcher +│ │ └── *.js # 34 движка симуляций +│ └── admin/ # Секции admin.html +│ ├── admin.js # Оркестратор + роутер +│ ├── router.js # Hash-based router +│ └── sections/ # overview, users, sessions, gam, ... ├── js/ -│ ├── api.js # window.LS.* — клиентское API -│ └── mobile.js # Мобильная адаптация +│ ├── api.js # window.LS.* — 200+ клиентских методов +│ ├── sidebar.js # Сайдбар с nav-avatar +│ └── mobile.js # Мобильная адаптация +├── plans/ # Планы фич (BIOCHEM_UPGRADE, STEREO_3D, ...) +├── docs/ # Руководства и планы ├── docker-compose.yml └── Dockerfile ``` @@ -219,26 +413,45 @@ Learn_System/ **Синхронизация доски** - Штрихи сохраняются батчами через `POST /api/classroom/:id/strokes` -- Загрузка с `?since_seq=N` — клиент получает только новые штрихи -- Live-превью через `POST /stroke-preview` → SSE `stroke_preview` событие -- Двухслойный canvas: статический слой (_strokes) + динамический (_selection/guides/laser) +- Загрузка с `?since_seq=N` — только новые штрихи +- Live-превью через `POST /stroke-preview` → SSE `stroke_preview` +- Двухслойный canvas: статический (_strokes) + динамический (_selection/guides/laser) **Real-time (SSE)** -- Один SSE-поток на пользователя: `GET /api/classroom/:id/events` -- События: `stroke_batch`, `stroke_preview`, `stroke_deleted`, `page_changed`, `chat_message`, `cursor_move`, `hand_raised`, `screen_share`, и др. -- Compression отключён для SSE-потоков +- Один SSE-поток на пользователя +- События: `stroke_batch`, `stroke_preview`, `stroke_deleted`, `page_changed`, `chat_message`, `cursor_move`, `hand_raised`, `screen_share` и др. **База данных** -- SQLite через `better-sqlite3` (синхронный API) -- Автоматические миграции при каждом старте сервера -- 76 таблиц, транзакционная запись батчей штрихов +- SQLite через `node:sqlite` (`DatabaseSync`, встроенный в Node.js 22+) +- Версионированные миграции (47 SQL-файлов, 000–046) +- 106 таблиц +- Транзакционная запись батчей штрихов **Аутентификация** -- JWT Bearer token +- JWT Bearer token, bcryptjs - Роли: `admin`, `teacher`, `student`, `free_student` - RBAC middleware с кешированием разрешений - Rate limiting: 6000 req/min для classroom, 600 req/min для остальных +**Химическое ядро (BIO)** +- `frontend/js/biochem-core.js` — dual-export: `window.BIO` в браузере, `module.exports` в Node +- `backend/src/services/chem.js` — переиспользует ядро без дублирования +- VSEPR-геометрия, частичные заряды, дипольный момент, баланс уравнений (Гаусс+НОК) + +**Доступ к контенту (content_access)** +- allowlist учебников и экзаменов по классам и конкретным ученикам +- `services/contentAccess.js`: `canAccessTextbook`, `filterTextbooks`, `allowedRefs` +- `/api/access` — admin CRUD + +**Lab Content Engine (LabRegistry)** +- Все симуляции: data-driven манифесты в `LabRegistry` +- Ленивая загрузка через `LabLoader.ensure(simId)` +- Каталог в БД (`lab_sims`): включение, featured, теги, привязка к учебникам + +**Shared модули (pet-sprite.js, lab-previews.js)** +- `pet-sprite.js` — канонический рендерер питомца, используется и на `/pet`, и на дашборде +- `lab-previews.js` — SVG-превью 6 симуляций для карточки «Лаборатория дня» + --- ## API @@ -247,21 +460,41 @@ Learn_System/ Аутентификация: `Authorization: Bearer ` -| Группа | Базовый путь | Назначение | -|--------|-------------|-----------| -| Auth | `/auth` | Регистрация, вход, профиль | +| Группа | Путь | Назначение | +|--------|------|-----------| +| Auth | `/auth` | Регистрация, вход, профиль, аватар | | Classroom | `/classroom` | Онлайн-урок, доска, чат, WebRTC | | Classes | `/classes` | Управление классами | | Assignments | `/assignments` | Задания и сдача работ | +| Submissions | `/submissions` | Сдача работ, статусы, оценки | | Questions | `/questions` | Банк вопросов | | Sessions | `/sessions` | Тестовые сессии | | Courses | `/courses` | Теоретические курсы | | Lessons | `/lessons` | Уроки с блоками контента | -| Gamification | `/gamification` | XP, ачивки, стрики | -| Files | `/files` | Загрузка и хранение файлов | +| Textbooks | `/textbooks` | Учебники, прогресс, закладки | +| Lab | `/lab` | Симуляции: каталог, управление | +| Biochem | `/biochem` | Молекулы, реакции, пути, analyze, validate | +| Gamification | `/gamification` | XP, уровни, ачивки, стрики, admin | +| Pet | `/pet` | Питомец, действия, магазин фонов | +| Shop | `/shop` | Виртуальный магазин | | Live | `/live` | Live-викторины | | Analytics | `/analytics` | Статистика | -| Admin | `/admin` | Управление платформой | +| Admin | `/admin` | Управление платформой, overview | +| Access | `/access` | Allowlist контента | +| Exam9 | `/exam9` | Экзаменационные тесты | +| Files | `/files` | Загрузка и хранение файлов | +| Notifications | `/notifications` | Уведомления | +| Permissions | `/permissions` | RBAC правила | +| Search | `/search` | Глобальный поиск | +| Preferences | `/preferences` | Пользовательские настройки | +| Parent | `/parent` | Кабинет родителя | +| Red Book | `/red-book` | Красная книга | +| Collection | `/collection` | Коллекции предметов | +| Games | `/games` | Игры (виселица, кроссворд) | +| Knowledge Map | `/knowledge-map` | Граф знаний | +| Flashcards | `/flashcards` | Флэшкарты | +| Templates | `/templates` | Шаблоны заданий | +| Teacher Students | `/teacher-students` | Ученики учителя без класса | Полная документация по endpoint'ам — в `backend/src/routes/`. @@ -271,12 +504,54 @@ Learn_System/ | Роль | Доступ | |------|--------| -| `admin` | Полный доступ ко всему, включая панель администратора | -| `teacher` | Создание классов, уроков, заданий, проведение онлайн-уроков | -| `student` | Прохождение тестов, участие в уроках, доступ к материалам | -| `free_student` | Ограниченный доступ (настраивается feature flags) | +| `admin` | Полный доступ, панель администратора, командный центр | +| `teacher` | Классы, уроки, задания, учебники, проведение онлайн-уроков | +| `student` | Тесты, уроки, учебники (по allowlist), лаборатория, питомец | +| `free_student` | Ограниченный доступ (настраивается через feature flags) | -Разрешения настраиваются гранулярно через `/api/permissions`. +Разрешения настраиваются гранулярно через `/api/permissions` (per-role и per-user). + +--- + +## Feature Flags + +Управляются через `app_settings` и API `/api/admin` (только admin). + +| Флаг | Назначение | По умолчанию | +|------|-----------|-------------| +| `feature_biochem_enabled` | Модуль биохимии | вкл | +| `feature_textbooks_enabled` | Модуль учебников | вкл | +| `feature_flashcards_enabled` | Флэшкарты | вкл | +| `feature_board_enabled` | Доска (board) | вкл | +| `feature_live_quiz_enabled` | Live-викторины | выкл | +| `feature_exam9_enabled` | Экзаменационные тесты | вкл | +| `sim_module_disabled` | Весь модуль симуляций | выкл | +| `sim_disabled_ids` | JSON-массив отключённых симуляций | `[]` | + +--- + +## Контент + +### Учебники + +| Предмет | Классы | Статус | +|---------|--------|--------| +| Химия | 7, 8, 9 | Полный курс с виджетами и анимациями | +| Физика | 7, 8, 9, 10, 11 | Структура + контент | +| Алгебра | 7, 8, 9, 10, 11 | Структура + контент | +| Геометрия | 7, 8, 9, 10, 11 | Структура + контент | + +### Сборники ЦТ/ЦЭ + +| Сборник | Вопросов | +|---------|---------| +| Физика 2019–2024 | 150+ | +| Математика 2021–2024 | 150+ | +| Экзамен-9 (математика) | 80 вариантов | + +### Симуляции + +40 симуляций в 5 категориях — см. раздел «Виртуальная лаборатория». --- diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..eb6c63a --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "js,json,yaml,yml", + "ignore": ["src/**/*.test.js"], + "delay": "250" +} diff --git a/backend/src/controllers/biochemController.js b/backend/src/controllers/biochemController.js index 9ac3eed..4de8572 100644 --- a/backend/src/controllers/biochemController.js +++ b/backend/src/controllers/biochemController.js @@ -1,6 +1,7 @@ 'use strict'; const db = require('../db/db'); const { awardXP, checkAchievements } = require('./gamificationController'); +const chem = require('../services/chem'); /* ── Helpers ─────────────────────────────────────────────────────────── */ const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 }; @@ -128,14 +129,26 @@ function validate(req, res) { return res.status(400).json({ error: 'atoms[] and bonds[] required' }); if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] }); - const formula = hillFormula(atoms); - const issues = valencyIssues(atoms, bonds); - const valid = issues.length === 0; - + // Единое химическое ядро (Фаза 2.1): формула + валентность с подсказками (2.4) + const { valid, formula, issues } = chem.validate(atoms, bonds); const known = valid ? stmts.getMolByFormula.get(formula) : null; res.json({ valid, formula, issues, known: known || null }); } +/* ── POST /api/biochem/analyze — полный химический анализ структуры (2.2) ─ */ +function analyze(req, res) { + const { atoms, bonds } = req.body || {}; + if (!Array.isArray(atoms)) + return res.status(400).json({ error: 'atoms[] обязателен' }); + if (atoms.length === 0) + return res.json({ formula: '', mass: 0, dbe: null, valency: [] }); + try { + res.json(chem.analyze(atoms, Array.isArray(bonds) ? bonds : [])); + } catch (e) { + res.status(500).json({ error: e.message }); + } +} + /* ── GET /api/biochem/reactions ─────────────────────────────────────── */ function getReactions(_req, res) { const rows = stmts.getReactions.all().map(r => ({ @@ -374,7 +387,7 @@ function tryParse(v, fallback) { } module.exports = { - getElements, getMolecules, getMolecule, validate, + getElements, getMolecules, getMolecule, validate, analyze, getReactions, getChallenges, solveChallenge, getSaved, saveMolecule, deleteSaved, getPathways, getPathwayProgress, savePathwayProgress, diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index b5a3109..33a30f6 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -126,6 +126,30 @@ function addCardsBulk(req, res) { res.json({ inserted }); } +/* ── PUT /api/flashcards/decks/:id/reorder ───────────────────────────────── + body: { order: [cardId, …] } — переписывает order_idx по позиции в массиве. + Принимаются только карточки, реально принадлежащие колоде владельца. */ +function reorderCards(req, res) { + const uid = req.user.id; + const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) + .get(req.params.id, uid); + if (!deck) return res.status(404).json({ error: 'Not found' }); + const { order } = req.body; + if (!Array.isArray(order) || !order.length) + return res.status(400).json({ error: 'order[] required' }); + + const owned = new Set( + db.prepare(`SELECT id FROM flashcard_cards WHERE deck_id = ?`).all(deck.id).map(r => r.id) + ); + const stmt = db.prepare(`UPDATE flashcard_cards SET order_idx = ? WHERE id = ? AND deck_id = ?`); + const run = db.transaction(() => { + let idx = 0; + order.forEach(id => { if (owned.has(Number(id))) stmt.run(idx++, Number(id), deck.id); }); + }); + run(); + res.json({ ok: true }); +} + /* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */ function updateCard(req, res) { const uid = req.user.id; @@ -241,8 +265,64 @@ function getStats(req, res) { res.json({ decks_count, cards_count, due_count, reviewed_today }); } +/* ── POST /api/flashcards/quick — быстрое добавление из любой точки ────── + Кладёт карточку в указанную колоду (deckId) либо в личную колоду + «Быстрые карточки» (создаётся при первом обращении). */ +const QUICK_DECK_TITLE = 'Быстрые карточки'; +function quickAdd(req, res) { + const uid = req.user.id; + const front = stripTags((req.body.front || '').slice(0, 5000)).trim(); + const back = stripTags((req.body.back || '').slice(0, 5000)).trim(); + if (!front) return res.status(400).json({ error: 'Лицевая сторона обязательна' }); + + let deck = null; + const deckId = Number(req.body.deckId) || 0; + if (deckId) { + deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE id = ? AND user_id = ?`) + .get(deckId, uid); + } + if (!deck) { + deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE user_id = ? AND title = ? ORDER BY id LIMIT 1`) + .get(uid, QUICK_DECK_TITLE); + if (!deck) { + const r = db.prepare( + `INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)` + ).run(uid, QUICK_DECK_TITLE, 'Карточки, добавленные на лету', '#9B5DE5'); + deck = { id: r.lastInsertRowid, title: QUICK_DECK_TITLE, color: '#9B5DE5', created: true }; + } + } + + const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) + .get(deck.id)?.m ?? -1; + const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) + .run(deck.id, front, back, maxIdx + 1); + res.json({ id: r.lastInsertRowid, deck_id: deck.id, deck_title: deck.title, deck_color: deck.color, front, back }); +} + +/* ── GET /api/flashcards/random — случайная карточка из всего пула ─────── + Для дашборд-виджета «повтори карточку». */ +function getRandom(req, res) { + const uid = req.user.id; + const total = db.prepare(` + SELECT COUNT(*) AS n FROM flashcard_cards c + JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id = ? + `).get(uid).n; + if (!total) return res.json({ card: null, total: 0 }); + + const card = db.prepare(` + SELECT c.id, c.front, c.back, c.deck_id, + d.title AS deck_title, d.color AS deck_color + FROM flashcard_cards c + JOIN flashcard_decks d ON d.id = c.deck_id + WHERE d.user_id = ? + ORDER BY RANDOM() LIMIT 1 + `).get(uid); + res.json({ card, total }); +} + module.exports = { listDecks, createDeck, updateDeck, deleteDeck, - getCards, addCard, addCardsBulk, updateCard, deleteCard, + getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards, getStudySession, submitReview, getStats, + quickAdd, getRandom, }; diff --git a/backend/src/controllers/questionController.js b/backend/src/controllers/questionController.js index 02575cb..54b8521 100644 --- a/backend/src/controllers/questionController.js +++ b/backend/src/controllers/questionController.js @@ -44,7 +44,7 @@ function list(req, res) { const sql = ` SELECT q.id, q.text, q.type, q.correct_text, q.difficulty, q.explanation, q.image, - q.year, q.source_type, + q.year, q.source_type, q.allow_html, t.name AS topic, t.id AS topic_id, s.name AS subject_name, s.slug AS subject_slug, (SELECT json_group_array(json_object( diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 98cef49..e63de06 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -24,7 +24,7 @@ const db = require('../db/db'); * paramKey — req.params key for the record ID (default: 'id') * adminBypass — admin role always passes (default: true) */ -const ALLOWED_TABLES = new Set(['tests','classes','assignments','questions','courses','lessons','files','folders','shop_items','live_sessions']); +const ALLOWED_TABLES = new Set(['tests','classes','assignments','questions','courses','lessons','files','folders','shop_items','live_sessions','flashcard_decks']); function requireOwnership({ table, fetchFn, ownerField, paramKey = 'id', adminBypass = true }) { if (table && !ALLOWED_TABLES.has(table)) throw new Error(`requireOwnership: unknown table "${table}"`); diff --git a/backend/src/routes/biochem.js b/backend/src/routes/biochem.js index 38ace09..bc37da4 100644 --- a/backend/src/routes/biochem.js +++ b/backend/src/routes/biochem.js @@ -8,6 +8,7 @@ router.get('/elements', c.getElements); router.get('/molecules', c.getMolecules); router.get('/molecules/:id', c.getMolecule); router.post('/validate', c.validate); +router.post('/analyze', c.analyze); router.get('/reactions', c.getReactions); router.get('/challenges', c.getChallenges); router.post('/challenges/:id/solve', c.solveChallenge); diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index 13d9a65..be0d81e 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -2,9 +2,12 @@ const express = require('express'); const router = express.Router(); const fc = require('../controllers/flashcardController'); const { authMiddleware } = require('../middleware/auth'); +const { requireOwnership } = require('../middleware/ownership'); router.use(authMiddleware); +router.post ('/quick', fc.quickAdd); +router.get ('/random', fc.getRandom); router.get ('/decks', fc.listDecks); router.post ('/decks', fc.createDeck); router.put ('/decks/:id', fc.updateDeck); @@ -12,6 +15,7 @@ router.delete('/decks/:id', fc.deleteDeck); router.get ('/decks/:id/cards', fc.getCards); router.post ('/decks/:id/cards', fc.addCard); 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/study', fc.getStudySession); router.put ('/cards/:id', fc.updateCard); router.delete('/cards/:id', fc.deleteCard); diff --git a/backend/src/services/chem.js b/backend/src/services/chem.js new file mode 100644 index 0000000..397af26 --- /dev/null +++ b/backend/src/services/chem.js @@ -0,0 +1,46 @@ +'use strict'; +/* + * chem.js — серверный химический слой (Фаза 2.1/2.2). + * + * Переиспользует то же ядро, что и фронт (frontend/js/biochem-core.js, + * `window.BIO`), вместо дублирования химии: формулы/масса/DBE, частичные + * заряды, дипольный момент (по 3D-геометрии VSEPR), полярность, функциональные + * группы, гибридизация, проверка валентности. + * + * Ядро самодостаточно (без DOM/canvas в чистых функциях) и при require в Node + * экспортирует объект BIO через module.exports. + */ +const path = require('path'); +const BIO = require(path.join(__dirname, '..', '..', '..', 'frontend', 'js', 'biochem-core.js')); + +/* Полный анализ структуры → {formula, mass, dbe, geometry, polarity, dipole, + * charges, groups, massFractions, valency}. Бросает на некорректном вводе. */ +function analyze(atoms, bonds) { + const an = BIO.analyze(atoms, bonds || []); + if (!an) return null; + return { + formula: an.formula, + mass: an.mass, + dbe: an.dbe, + atomCount: an.atomCount, + geometry: an.geometry, // {shape, hybridization, angle, centerSym} + polarity: an.polarity ? an.polarity.label : null, // «Полярная» / «Неполярная» / … + dipole: an.dipole, + charges: an.charges, + groups: an.groups, + massFractions: an.massFractions, + valency: BIO.valency(atoms, bonds || []), + }; +} + +/* Проверка корректности: формула + проблемы валентности (с подсказками). */ +function validate(atoms, bonds) { + const issues = BIO.valency(atoms, bonds || []); + return { + valid: issues.length === 0, + formula: BIO.hillFormula(atoms || []), + issues, + }; +} + +module.exports = { analyze, validate, BIO }; diff --git a/backend/tests/chemistry7-page.test.js b/backend/tests/chemistry7-page.test.js index dc4c4c6..bfe152c 100644 --- a/backend/tests/chemistry7-page.test.js +++ b/backend/tests/chemistry7-page.test.js @@ -23,8 +23,11 @@ function buildPage(file) { '/js/biochem-core.js': readF('frontend/js/biochem-core.js'), '/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'), '/js/chem7_svg.js': readF('frontend/js/chem7_svg.js'), + '/js/chem7_anim.js': readF('frontend/js/chem7_anim.js'), '/js/chem7_ch1_widgets.js': readF('frontend/js/chem7_ch1_widgets.js'), '/js/chem7_ch2_widgets.js': readF('frontend/js/chem7_ch2_widgets.js'), + '/js/chem7_ch3_widgets.js': readF('frontend/js/chem7_ch3_widgets.js'), + '/js/chem7_ch4_widgets.js': readF('frontend/js/chem7_ch4_widgets.js'), '/js/chem8_engine.js': readF('frontend/js/chem8_engine.js') }; html = html @@ -96,6 +99,29 @@ test('ch1 Волна 2: интерактивы §4–§6 монтируются assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); }); +test('ch1 V-пилот: 3D-молекулы §5/§6 + анимация разделения §2', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch1.html'); + doc.defaultView.goTo('p5'); await wait(120); + assert.ok(doc.querySelector('#p5-gal .mv-b'), 'переключатель молекул §5'); + assert.ok(doc.querySelector('#p5-gal-stage svg circle'), '3D-молекула §5 (SVG)'); + doc.defaultView.goTo('p6'); await wait(120); + assert.ok(doc.querySelector('#p6-gal-stage svg circle'), '3D-молекула §6 (SVG)'); + doc.defaultView.goTo('p2'); await wait(120); + const btn = [...doc.querySelectorAll('#p2-sep .c7-m')].find(b => b.dataset.m === 'Фильтрование'); + assert.ok(btn, 'кнопка верного метода §2 найдена'); + btn.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(50); + assert.ok(doc.querySelector('#p2-sep-anim canvas'), 'сцена разделения §2 (canvas)'); + // §10: анимация признаков реакции после «Провести опыт» + doc.defaultView.goTo('p10'); await wait(120); + doc.getElementById('p10-signs-go').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(40); + assert.ok(doc.querySelector('#p10-signs-stage div'), 'анимация признаков реакции §10'); + // §11: осадок появляется при «Смешать» + doc.defaultView.goTo('p11'); await wait(120); + doc.getElementById('p11-mix').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(40); + assert.ok(doc.querySelector('#p11-stage div'), 'анимация осадка §11'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + test('ch1 Волна 3: интерактивы §7–§9 монтируются и считают', async () => { const { doc, errors } = await loadDom('chemistry_7_ch1.html'); doc.defaultView.goTo('p7'); await wait(100); @@ -105,6 +131,7 @@ test('ch1 Волна 3: интерактивы §7–§9 монтируются assert.match(doc.querySelector('#p8-out').textContent, /100/, 'M_r(CaCO3)=100'); doc.defaultView.goTo('p9'); await wait(100); assert.ok(doc.querySelector('#p9-bld #p9-a'), 'конструктор валентности §9'); + assert.ok(doc.querySelector('#p9-vis svg circle'), 'схема валентных связей §9'); assert.match(doc.querySelector('#p9-bout').textContent, /Al/, 'формула по валентности построена'); assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); }); @@ -119,6 +146,7 @@ test('ch1 Волна 4: §10–§12 + ЛО1 + финал главы монтир assert.ok(doc.querySelector('#p11-bal svg'), 'весы сохранения массы §11'); doc.defaultView.goTo('p12'); await wait(120); assert.ok(doc.querySelector('#p12-mount').childElementCount > 0, 'балансировщик §12'); + assert.ok(doc.querySelector('#p12-tally .c7-atom'), 'подсчёт атомов §12 (летящие атомы)'); doc.defaultView.goTo('final1'); await wait(120); assert.ok(doc.querySelectorAll('#navDotsfinal1 .nav-dot').length >= 6, 'боссы финала главы'); assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); @@ -145,6 +173,76 @@ test('ch2 Волна 1: интерактивы §13 + ЛО2 + §14 + §15 мон assert.ok(doc.querySelector('#p15-burn #p15-go'), 'симулятор горения §15'); doc.defaultView.goTo('p15'); doc.getElementById('p15-go').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); assert.match(doc.querySelector('#p15-out').textContent, /оксид/, 'горение даёт оксид'); + assert.ok(doc.querySelector('#p15-stage div'), 'анимация пламени §15'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + +test('ch2 Волна 2: §16 + §17 + ПР2 + финал главы монтируются', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch2.html'); + doc.defaultView.goTo('p16'); await wait(100); + assert.ok(doc.querySelector('#p16-bld #p16-el'), 'конструктор оксида §16'); + assert.ok(doc.querySelector('#p16-cls .c7-chip'), 'классификатор оксид/не оксид §16'); + doc.defaultView.goTo('p17'); await wait(100); + assert.ok(doc.querySelector('#p17-prod #p17-pick'), 'схема получения O2 §17'); + doc.defaultView.goTo('pr2'); await wait(100); + assert.ok(doc.querySelector('#pr2-test #pr2-go'), 'проверка кислорода ПР2'); + doc.defaultView.goTo('final2'); await wait(120); + assert.ok(doc.querySelectorAll('#navDotsfinal2 .nav-dot').length >= 6, 'боссы финала главы 2'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + +test('ch3 Волна 1: §18 + §19 + §20 + ЛО3 монтируются', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch3.html'); + assert.ok(doc.querySelector('#p18-card svg'), 'паспорт водорода §18'); + doc.defaultView.goTo('p19'); await wait(100); + assert.ok(doc.querySelector('#p19-rx #p19-pick'), 'реакции водорода §19'); + assert.ok(doc.querySelector('#p19-stage div'), 'анимация реакции §19'); + doc.defaultView.goTo('p20'); await wait(100); + assert.ok(doc.querySelector('#p20-ind #p20-ind-ind'), 'индикаторы §20'); + assert.ok(doc.querySelector('#p20-ind-drop div'), 'анимация индикатора §20'); + assert.ok(doc.querySelector('#p20-acids table'), 'таблица кислот §20'); + doc.defaultView.goTo('lo3'); await wait(100); + assert.ok(doc.querySelector('#lo3-ind #lo3-ind-ind'), 'индикаторы ЛО3'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + +test('ch3 Волна 2: §21 + ЛО4 + §22 + ПР3 + финал главы монтируются', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch3.html'); + doc.defaultView.goTo('p21'); await wait(100); + assert.ok(doc.querySelector('#p21-act .act-cell'), 'ряд активности §21'); + // клик по Zn (левее H₂) → пузырьки H₂ + doc.querySelector('#p21-act .act-cell[data-i="5"]').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(40); + assert.ok(doc.querySelector('#p21-tube div'), 'пузырьки H₂ при реакции металла с кислотой §21'); + doc.defaultView.goTo('lo4'); await wait(100); + assert.ok(doc.querySelector('#lo4-rx #lo4-go'), 'опыт металл+кислота ЛО4'); + doc.defaultView.goTo('p22'); await wait(100); + assert.ok(doc.querySelector('#p22-salt #p22-m'), 'конструктор солей §22'); + doc.defaultView.goTo('pr3'); await wait(100); + assert.ok(doc.querySelector('#pr3-test #pr3-mix'), 'проверка чистоты H2 ПР3'); + doc.defaultView.goTo('final3'); await wait(120); + assert.ok(doc.querySelectorAll('#navDotsfinal3 .nav-dot').length >= 6, 'боссы финала главы 3'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + +test('ch4: вся глава 4 (§23–§26 + ЛО5 + ПР4 + финал) монтируется', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch4.html'); + assert.ok(doc.querySelector('#p23-water #p23-pick'), 'разложение/реакции воды §23'); + assert.ok(doc.querySelector('#p23-bub-h div'), 'пузырьки электролиза 2:1 §23'); + doc.defaultView.goTo('p24'); await wait(100); + assert.ok(doc.querySelector('#p24-bld #p24-m'), 'конструктор оснований §24'); + assert.ok(doc.querySelector('#p24-ind #p24-ind-sel'), 'индикаторы щёлочи §24'); + assert.ok(doc.querySelector('#p24-ind-drop div'), 'анимация индикатора §24'); + doc.defaultView.goTo('lo5'); await wait(100); + assert.ok(doc.querySelector('#lo5-ind #lo5-ind-sel'), 'индикаторы ЛО5'); + doc.defaultView.goTo('p25'); await wait(100); + assert.ok(doc.querySelector('#p25-neu #p25-neu-go'), 'нейтрализация §25'); + assert.ok(doc.querySelector('#p25-neu-cup div'), 'анимация раствора §25'); + doc.defaultView.goTo('pr4'); await wait(100); + assert.ok(doc.querySelector('#pr4-neu #pr4-neu-go'), 'нейтрализация ПР4'); + doc.defaultView.goTo('p26'); await wait(100); + assert.ok(doc.querySelector('#p26-eco .eco-it'), 'экология §26'); + doc.defaultView.goTo('final4'); await wait(120); + assert.ok(doc.querySelectorAll('#navDotsfinal4 .nav-dot').length >= 6, 'боссы финала главы 4'); assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); }); diff --git a/frontend/admin.html b/frontend/admin.html index f5f2d30..02af9fc 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -653,6 +653,74 @@ .adm-toggle .thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.15); transition: transform .2s; } .adm-toggle input:checked ~ .track { background: var(--green, #06d6a0); } .adm-toggle input:checked ~ .thumb { transform: translateX(18px); } + /* ── Gam award form ─────────────────────────────────────────────────── */ + .gam-award-grid { display: grid; grid-template-columns: 280px 1fr; gap: 28px; align-items: start; } + @media (max-width: 800px) { .gam-award-grid { grid-template-columns: 1fr; } } + + .gam-user-col { display: flex; flex-direction: column; gap: 6px; } + .gam-user-filter { + padding: 9px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; + font-family: 'Manrope', sans-serif; font-size: 0.88rem; background: var(--surface); color: var(--text); + transition: border-color .15s; box-sizing: border-box; width: 100%; + } + .gam-user-filter:focus { outline: none; border-color: var(--violet); } + .gam-user-select { + border: 1.5px solid var(--border-h); border-radius: 10px; + font-family: 'Manrope', sans-serif; font-size: 0.88rem; background: var(--surface); color: var(--text); + overflow-y: auto; width: 100%; box-sizing: border-box; + transition: border-color .15s; + } + .gam-user-select:focus { outline: none; border-color: var(--violet); } + .gam-user-select option { padding: 7px 12px; } + .gam-user-select option:checked { background: rgba(155,93,229,0.1); color: var(--violet); } + + .gam-fields { display: flex; flex-direction: column; gap: 18px; } + .gam-field-row { display: flex; flex-direction: column; gap: 6px; } + .gam-field-row label { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; } + .gam-input-row { display: flex; align-items: center; gap: 8px; } + .gam-num-input { + width: 88px; padding: 9px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; + font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 700; text-align: center; + background: var(--surface); color: var(--text); transition: border-color .15s; + } + .gam-num-input:focus { outline: none; border-color: var(--violet); } + + .gam-presets { display: flex; gap: 5px; flex-wrap: wrap; } + .gam-preset { + padding: 6px 13px; border: 1.5px solid var(--border-h); border-radius: 8px; + background: var(--surface); color: var(--text-2); + font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; + cursor: pointer; transition: border-color .15s, background .15s, color .15s; + } + .gam-preset:hover { border-color: var(--violet); color: var(--violet); } + .gam-preset.active { background: var(--violet); color: #fff; border-color: var(--violet); } + + .gam-reason-presets { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 6px; } + .gam-reason-tag { + padding: 5px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; + background: var(--surface); color: var(--text-3); + font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600; + cursor: pointer; transition: border-color .15s, color .15s; + } + .gam-reason-tag:hover { border-color: var(--cyan); color: var(--cyan); } + .gam-reason-input { + width: 100%; padding: 9px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; + font-family: 'Manrope', sans-serif; font-size: 0.88rem; + background: var(--surface); color: var(--text); box-sizing: border-box; + transition: border-color .15s; + } + .gam-reason-input:focus { outline: none; border-color: var(--violet); } + + .gam-award-footer { display: flex; align-items: center; gap: 14px; padding-top: 20px; border-top: 1px solid var(--border); margin-top: 6px; } + .gam-award-hint { font-size: 0.78rem; color: var(--text-3); line-height: 1.4; } + + .gam-reset-grid { display: grid; grid-template-columns: 280px 1fr; gap: 28px; align-items: start; } + @media (max-width: 800px) { .gam-reset-grid { grid-template-columns: 1fr; } } + .gam-reset-warning { + background: rgba(241,91,181,0.06); border: 1.5px solid rgba(241,91,181,0.2); + border-radius: 12px; padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; + } + .gam-reset-warning p { font-size: 0.84rem; color: var(--text-2); line-height: 1.5; } .adm-user-search { position: relative; } .adm-user-search .us-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #fff; border: 1.5px solid var(--border-h); border-radius: 12px; max-height: 240px; overflow-y: auto; box-shadow: 0 8px 24px rgba(15,23,42,0.12); display: none; } .adm-user-search .us-results.open { display: block; } @@ -1376,39 +1444,89 @@
Начислить XP / Монеты
-
-