merge: feature/lab-content-engine → master
This commit is contained in:
@@ -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=<id>` открывает конкретную симуляцию
|
||||
- Курикулумная привязка: 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 <token>`
|
||||
|
||||
| Группа | Базовый путь | Назначение |
|
||||
|--------|-------------|-----------|
|
||||
| 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 категориях — см. раздел «Виртуальная лаборатория».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "js,json,yaml,yml",
|
||||
"ignore": ["src/**/*.test.js"],
|
||||
"delay": "250"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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(' | '));
|
||||
});
|
||||
|
||||
|
||||
+143
-25
@@ -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 @@
|
||||
|
||||
<div class="section-title" style="margin-top:32px">Начислить XP / Монеты</div>
|
||||
<div class="adm-panel">
|
||||
<div class="adm-form-row">
|
||||
<div class="adm-form-group adm-user-search" style="flex:1">
|
||||
<label>Пользователь</label>
|
||||
<input type="text" id="gam-award-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-award')" />
|
||||
<div class="us-results" id="gam-award-results"></div>
|
||||
<input type="hidden" id="gam-award-uid" />
|
||||
<div class="gam-award-grid">
|
||||
|
||||
<!-- Левая колонка: выбор пользователя -->
|
||||
<div class="gam-user-col">
|
||||
<label style="font-size:0.72rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em">Пользователь</label>
|
||||
<input class="gam-user-filter" type="text" placeholder="Фильтр по имени…"
|
||||
autocomplete="off" oninput="gamFilterUsers(this.value,'gam-award-uid')" />
|
||||
<select id="gam-award-uid" class="gam-user-select" size="7">
|
||||
<option value="">— загрузка… —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adm-form-group" style="width:100px">
|
||||
<label>XP</label>
|
||||
<input type="number" id="gam-award-xp" min="0" value="10" />
|
||||
|
||||
<!-- Правая колонка: XP / Монеты / Причина -->
|
||||
<div class="gam-fields">
|
||||
|
||||
<div class="gam-field-row">
|
||||
<label>XP</label>
|
||||
<div class="gam-input-row">
|
||||
<input type="number" id="gam-award-xp" class="gam-num-input" min="0" value="0" />
|
||||
<div class="gam-presets">
|
||||
<button class="gam-preset gam-xp-preset active" data-xp="0" onclick="gamSetXP(0)">0</button>
|
||||
<button class="gam-preset gam-xp-preset" data-xp="10" onclick="gamSetXP(10)">+10</button>
|
||||
<button class="gam-preset gam-xp-preset" data-xp="25" onclick="gamSetXP(25)">+25</button>
|
||||
<button class="gam-preset gam-xp-preset" data-xp="50" onclick="gamSetXP(50)">+50</button>
|
||||
<button class="gam-preset gam-xp-preset" data-xp="100" onclick="gamSetXP(100)">+100</button>
|
||||
<button class="gam-preset gam-xp-preset" data-xp="250" onclick="gamSetXP(250)">+250</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gam-field-row">
|
||||
<label>Монеты</label>
|
||||
<div class="gam-input-row">
|
||||
<input type="number" id="gam-award-coins" class="gam-num-input" min="0" value="0" />
|
||||
<div class="gam-presets">
|
||||
<button class="gam-preset gam-coins-preset active" data-coins="0" onclick="gamSetCoins(0)">0</button>
|
||||
<button class="gam-preset gam-coins-preset" data-coins="10" onclick="gamSetCoins(10)">+10</button>
|
||||
<button class="gam-preset gam-coins-preset" data-coins="25" onclick="gamSetCoins(25)">+25</button>
|
||||
<button class="gam-preset gam-coins-preset" data-coins="50" onclick="gamSetCoins(50)">+50</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gam-field-row">
|
||||
<label>Причина</label>
|
||||
<div class="gam-reason-presets">
|
||||
<button class="gam-reason-tag" onclick="gamSetReason('Admin award')">Ручное начисление</button>
|
||||
<button class="gam-reason-tag" onclick="gamSetReason('За участие в мероприятии')">Мероприятие</button>
|
||||
<button class="gam-reason-tag" onclick="gamSetReason('За активность на уроке')">Активность</button>
|
||||
<button class="gam-reason-tag" onclick="gamSetReason('Бонус за отличную работу')">Бонус</button>
|
||||
<button class="gam-reason-tag" onclick="gamSetReason('Компенсация (техническая)')">Компенсация</button>
|
||||
</div>
|
||||
<input type="text" id="gam-award-reason" class="gam-reason-input" placeholder="Или введите свою причину…" />
|
||||
</div>
|
||||
|
||||
<div class="gam-award-footer">
|
||||
<button class="adm-btn adm-btn-primary" onclick="gamAdminAward()">
|
||||
<i data-lucide="zap" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"></i>Начислить
|
||||
</button>
|
||||
<span class="gam-award-hint">XP = 0 или Монеты = 0 — эта валюта не начисляется</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="adm-form-group" style="width:100px">
|
||||
<label>Монеты</label>
|
||||
<input type="number" id="gam-award-coins" min="0" value="0" />
|
||||
</div>
|
||||
<div class="adm-form-group" style="flex:1">
|
||||
<label>Причина</label>
|
||||
<input type="text" id="gam-award-reason" placeholder="За участие" />
|
||||
</div>
|
||||
<button class="adm-btn adm-btn-primary" onclick="gamAdminAward()" style="align-self:flex-end">Начислить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:32px">Сбросить прогресс пользователя</div>
|
||||
<div class="adm-panel">
|
||||
<div class="adm-form-row">
|
||||
<div class="adm-form-group adm-user-search" style="flex:1">
|
||||
<label>Пользователь</label>
|
||||
<input type="text" id="gam-reset-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-reset')" />
|
||||
<div class="us-results" id="gam-reset-results"></div>
|
||||
<input type="hidden" id="gam-reset-uid" />
|
||||
<div class="gam-reset-grid">
|
||||
<div class="gam-user-col">
|
||||
<label style="font-size:0.72rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em">Пользователь</label>
|
||||
<input class="gam-user-filter" type="text" placeholder="Фильтр по имени…"
|
||||
autocomplete="off" oninput="gamFilterUsers(this.value,'gam-reset-uid')" />
|
||||
<select id="gam-reset-uid" class="gam-user-select" size="5">
|
||||
<option value="">— загрузка… —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gam-reset-warning">
|
||||
<p>Удалит весь XP, монеты, достижения и историю начислений. Действие необратимо и не может быть отменено.</p>
|
||||
<button class="adm-btn adm-btn-danger" onclick="gamAdminReset()">
|
||||
<i data-lucide="trash-2" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"></i>Сбросить прогресс
|
||||
</button>
|
||||
</div>
|
||||
<button class="adm-btn adm-btn-danger" onclick="gamAdminReset()" style="align-self:flex-end">Сбросить прогресс</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,7 +278,44 @@
|
||||
.page-header { padding: 10px; }
|
||||
.filters-row { padding: 8px 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||||
.biochem-subnav {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
scrollbar-width: none; position: relative;
|
||||
}
|
||||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||||
.biochem-subnav::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||||
}
|
||||
.bsn-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s, color .15s; position: relative;
|
||||
}
|
||||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||||
.bsn-active {
|
||||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||||
}
|
||||
.bsn-active::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.biochem-subnav { padding: 0 6px; }
|
||||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||||
.bsn-tab .bsn-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout" id="app">
|
||||
@@ -286,6 +323,9 @@
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
|
||||
<div class="sb-content">
|
||||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||||
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab bsn-active" href="/biochem-library" aria-current="page"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||||
</nav>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg></div>
|
||||
@@ -368,7 +408,7 @@ if (!user) location.href = '/login';
|
||||
const nav = document.getElementById('nav-user');
|
||||
const ava = document.getElementById('nav-avatar');
|
||||
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
|
||||
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
LS.renderNavAvatar(ava, user);
|
||||
if (isAdmin) document.getElementById('btn-admin').style.display = '';
|
||||
if (isTeacher) document.getElementById('btn-classes').style.display = '';
|
||||
LS.showBoardIfAllowed();
|
||||
@@ -612,5 +652,6 @@ init();
|
||||
</script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/biochem-nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -338,7 +338,44 @@
|
||||
.path-chip { font-size: 0.68rem; padding: 4px 10px; }
|
||||
.side-panel { max-height: 45vh; }
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||||
.biochem-subnav {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
scrollbar-width: none; position: relative;
|
||||
}
|
||||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||||
.biochem-subnav::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||||
}
|
||||
.bsn-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s, color .15s; position: relative;
|
||||
}
|
||||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||||
.bsn-active {
|
||||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||||
}
|
||||
.bsn-active::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.biochem-subnav { padding: 0 6px; }
|
||||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||||
.bsn-tab .bsn-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout" id="app">
|
||||
@@ -346,6 +383,9 @@
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
|
||||
<div class="sb-content">
|
||||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||||
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab bsn-active" href="/biochem-pathways" aria-current="page"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||||
</nav>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14l2-7"/><path d="M12 14l2-7"/></svg></div>
|
||||
@@ -1040,5 +1080,6 @@ init();
|
||||
</script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/biochem-nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -192,7 +192,44 @@
|
||||
.props-sidebar { max-height: 40vh; }
|
||||
.mol-card-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||||
.biochem-subnav {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
scrollbar-width: none; position: relative;
|
||||
}
|
||||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||||
.biochem-subnav::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||||
}
|
||||
.bsn-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s, color .15s; position: relative;
|
||||
}
|
||||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||||
.bsn-active {
|
||||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||||
}
|
||||
.bsn-active::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.biochem-subnav { padding: 0 6px; }
|
||||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||||
.bsn-tab .bsn-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout" id="app">
|
||||
@@ -200,6 +237,9 @@
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
|
||||
<div class="sb-content" style="overflow:hidden;display:flex;flex-direction:column">
|
||||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||||
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab bsn-active" href="/biochem-properties" aria-current="page"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||||
</nav>
|
||||
<div class="props-layout">
|
||||
<!-- Left: molecule list -->
|
||||
<div class="props-sidebar">
|
||||
@@ -249,7 +289,7 @@ if (!user) location.href = '/login';
|
||||
const nav = document.getElementById('nav-user');
|
||||
const ava = document.getElementById('nav-avatar');
|
||||
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
|
||||
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
LS.renderNavAvatar(ava, user);
|
||||
if (isAdmin) document.getElementById('btn-admin').style.display = '';
|
||||
if (isTeacher) document.getElementById('btn-classes').style.display = '';
|
||||
LS.showBoardIfAllowed();
|
||||
@@ -618,5 +658,6 @@ LS.hideDisabledFeatures?.();
|
||||
</script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/biochem-nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -316,7 +316,44 @@
|
||||
.page-header { padding: 10px; }
|
||||
.rxn-card { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||||
.biochem-subnav {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
scrollbar-width: none; position: relative;
|
||||
}
|
||||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||||
.biochem-subnav::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||||
}
|
||||
.bsn-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s, color .15s; position: relative;
|
||||
}
|
||||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||||
.bsn-active {
|
||||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||||
}
|
||||
.bsn-active::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.biochem-subnav { padding: 0 6px; }
|
||||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||||
.bsn-tab .bsn-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout" id="app">
|
||||
@@ -324,6 +361,9 @@
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
|
||||
<div class="sb-content">
|
||||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||||
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab bsn-active" href="/biochem-reactions" aria-current="page"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||||
</nav>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header-icon"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
|
||||
@@ -369,7 +409,7 @@ if (!user) location.href = '/login';
|
||||
const nav = document.getElementById('nav-user');
|
||||
const ava = document.getElementById('nav-avatar');
|
||||
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
|
||||
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
LS.renderNavAvatar(ava, user);
|
||||
if (isAdmin) document.getElementById('btn-admin').style.display = '';
|
||||
if (isTeacher) document.getElementById('btn-classes').style.display = '';
|
||||
LS.showBoardIfAllowed();
|
||||
@@ -836,5 +876,6 @@ init();
|
||||
</script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/biochem-nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+46
-10
@@ -332,7 +332,44 @@
|
||||
.el-btn { width: 28px; height: 28px; font-size: 0.66rem; }
|
||||
.tool-btn { height: 26px; padding: 0 6px; font-size: 0.66rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||||
.biochem-subnav {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
scrollbar-width: none; position: relative;
|
||||
}
|
||||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||||
.biochem-subnav::after {
|
||||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||||
}
|
||||
.bsn-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s, color .15s; position: relative;
|
||||
}
|
||||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||||
.bsn-active {
|
||||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||||
}
|
||||
.bsn-active::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.biochem-subnav { padding: 0 6px; }
|
||||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||||
.bsn-tab .bsn-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout" id="app">
|
||||
@@ -340,6 +377,9 @@
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
|
||||
<div class="sb-content">
|
||||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||||
<a class="bsn-tab bsn-active" href="/biochem" aria-current="page"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||||
</nav>
|
||||
<!-- ── Toolbar ── -->
|
||||
<div class="bio-toolbar">
|
||||
<span class="bio-title">Биохимия</span>
|
||||
@@ -532,7 +572,7 @@ if (!user) location.href = '/login';
|
||||
const nav = document.getElementById('nav-user');
|
||||
const ava = document.getElementById('nav-avatar');
|
||||
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
|
||||
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
LS.renderNavAvatar(ava, user);
|
||||
if (isAdmin) { document.getElementById('btn-admin').style.display=''; }
|
||||
if (isTeacher) { document.getElementById('btn-classes').style.display=''; }
|
||||
LS.showBoardIfAllowed();
|
||||
@@ -683,13 +723,8 @@ function getBondSum(id) {
|
||||
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? (b.order||b.o||1) : 0), 0);
|
||||
}
|
||||
function getIssues() {
|
||||
return atoms.filter(a => {
|
||||
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
|
||||
return used > (ELEMENTS[a.s]?.maxV ?? 4);
|
||||
}).map(a => {
|
||||
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
|
||||
return { id:a.id, s:a.s, used, max: ELEMENTS[a.s]?.maxV??4 };
|
||||
});
|
||||
// Единая проверка валентности из ядра (с подсказками, Фаза 2.4)
|
||||
return BIO.valency(atoms, bonds);
|
||||
}
|
||||
|
||||
// ── Live molecular stats ──
|
||||
@@ -1192,7 +1227,7 @@ function updateInfo() {
|
||||
const issues = getIssues();
|
||||
const issDiv = document.getElementById('bp-issues');
|
||||
if (issues.length) {
|
||||
issDiv.innerHTML = issues.map(i => `<div class="bp-issue"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ${i.s}: ${i.used}/${i.max} связей</div>`).join('');
|
||||
issDiv.innerHTML = issues.map(i => `<div class="bp-issue"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ${i.msg}</div>`).join('');
|
||||
} else if (formula) {
|
||||
issDiv.innerHTML = '<div class="bp-ok"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Валентность в норме</div>';
|
||||
} else {
|
||||
@@ -2006,5 +2041,6 @@ LS.loadFeatures?.().then(feats => {
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script src="/js/biochem-nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+439
-130
@@ -67,20 +67,78 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
||||
.action-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.ac-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 14px;
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||||
text-decoration: none; color: inherit; transition: all 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
|
||||
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
||||
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.hero-card {
|
||||
position: relative; border-radius: 18px; padding: 18px 20px;
|
||||
display: flex; flex-direction: column; min-height: 196px;
|
||||
text-decoration: none; color: inherit; overflow: hidden;
|
||||
transition: transform 0.16s, box-shadow 0.16s;
|
||||
}
|
||||
.ac-card:hover { border-color: rgba(155,93,229,0.25); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(15,23,42,0.08); }
|
||||
.ac-emoji { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.ac-body { flex: 1; min-width: 0; }
|
||||
.ac-title { font-size: 0.84rem; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ac-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ac-badge { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
|
||||
.hero-card:hover { transform: translateY(-3px); box-shadow: 0 14px 34px rgba(15,23,42,0.16); }
|
||||
.hc-tag {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 0.66rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hc-tag svg { width: 15px; height: 15px; }
|
||||
.hc-h { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; line-height: 1.15; }
|
||||
.hc-p { font-size: 0.76rem; line-height: 1.45; margin-top: 7px; }
|
||||
.hc-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 14px; }
|
||||
.hc-meta { font-size: 0.7rem; font-weight: 600; }
|
||||
.hc-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 15px; border-radius: 99px; flex-shrink: 0;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
||||
border: none; cursor: pointer; transition: filter 0.15s, transform 0.12s;
|
||||
}
|
||||
.hc-btn svg { width: 14px; height: 14px; }
|
||||
.hc-btn:hover { filter: brightness(1.08); }
|
||||
.hc-btn:active { transform: translateY(1px); }
|
||||
.hc-progress { height: 6px; border-radius: 99px; margin-top: 12px; overflow: hidden; }
|
||||
.hc-progress > i { display: block; height: 100%; border-radius: 99px; }
|
||||
|
||||
/* Card 1 — Reading (warm gradient) */
|
||||
.hc-read { background: linear-gradient(135deg, #d9742a 0%, #b3531a 100%); color: #fff; }
|
||||
.hc-read .hc-tag { color: rgba(255,255,255,.82); }
|
||||
.hc-read .hc-p { color: rgba(255,255,255,.78); }
|
||||
.hc-read .hc-meta { color: rgba(255,255,255,.7); }
|
||||
.hc-read .hc-progress { background: rgba(255,255,255,.2); }
|
||||
.hc-read .hc-progress > i { background: rgba(255,255,255,.92); }
|
||||
.hc-read .hc-pct { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 0.82rem; color: #fff; }
|
||||
.hc-read .hc-btn { background: #fff; color: #b3531a; }
|
||||
|
||||
/* Card 2 — Lab of day (dark) */
|
||||
.hc-lab { background: linear-gradient(150deg, #16131f 0%, #1d1830 100%); color: #fff; border: 1px solid rgba(155,93,229,.18); }
|
||||
.hc-lab .hc-bg { position: absolute; inset: 0; opacity: .5; z-index: 0; }
|
||||
.hc-lab .hc-bg svg { width: 100%; height: 100%; }
|
||||
.hc-lab > *:not(.hc-bg) { position: relative; z-index: 1; }
|
||||
.hc-lab .hc-tag { color: #06D6E0; }
|
||||
.hc-lab .hc-tag svg { stroke: #06D6E0; }
|
||||
.hc-lab .hc-p { color: rgba(255,255,255,.74); }
|
||||
.hc-lab .hc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.hc-lab .hc-chip { font-size: 0.66rem; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.82); }
|
||||
.hc-lab .hc-chip.subj { background: rgba(6,214,224,.16); color: #06D6E0; }
|
||||
.hc-lab .hc-meta { color: rgba(255,255,255,.6); }
|
||||
.hc-lab .hc-btn { background: rgba(255,255,255,.12); color: #fff; backdrop-filter: blur(6px); }
|
||||
|
||||
/* Card 3 — Pet (light, accent top) */
|
||||
.hc-pet { background: var(--surface, #fff); border: 1.5px solid rgba(15,23,42,.07); border-top: 3px solid #F9C74F; }
|
||||
.hc-pet .hc-tag { color: #b3531a; }
|
||||
.hc-pet .hc-tag svg { stroke: #F9C74F; }
|
||||
.hc-pet .hc-pet-top { display: flex; align-items: center; gap: 10px; }
|
||||
.hc-pet .hc-pet-name { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; }
|
||||
.hc-pet .hc-pet-art { width: 50px; height: 50px; margin-left: auto; flex-shrink: 0; }
|
||||
.hc-pet .hc-pet-art svg { width: 100%; height: 100%; }
|
||||
.hc-pet .hc-xp-row { display: flex; align-items: baseline; justify-content: space-between; margin-top: 12px; font-size: 0.7rem; color: var(--text-3); }
|
||||
.hc-pet .hc-xp-row b { color: var(--text); font-weight: 800; }
|
||||
.hc-pet .hc-progress { background: rgba(15,23,42,.07); }
|
||||
.hc-pet .hc-progress > i { background: linear-gradient(90deg, #F9C74F, #F98231); }
|
||||
.hc-pet .hc-pet-chips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 12px; }
|
||||
.hc-pet .hc-pchip { text-align: center; padding: 6px 2px; border-radius: 10px; background: rgba(15,23,42,.04); }
|
||||
.hc-pet .hc-pchip b { display: block; font-family: 'Unbounded', sans-serif; font-size: 0.86rem; font-weight: 800; color: var(--text); }
|
||||
.hc-pet .hc-pchip span { display: block; font-size: 0.58rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-3); margin-top: 2px; }
|
||||
.hc-pet .hc-btn { background: rgba(249,199,79,.16); color: #b3531a; align-self: flex-start; }
|
||||
|
||||
/* ── ZONE 3: Three-Column Grid ── */
|
||||
.main-grid {
|
||||
@@ -90,6 +148,15 @@
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.full-row { margin-bottom: 22px; }
|
||||
/* Bottom row: Activity · My submissions · Challenges side by side */
|
||||
.bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 22px;
|
||||
margin-bottom: 22px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.bottom-grid > * { margin-bottom: 0; height: 100%; }
|
||||
|
||||
.qa-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -245,6 +312,39 @@
|
||||
.cont-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
|
||||
.cont-pct { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--violet); }
|
||||
|
||||
/* ── flashcard review widget ── */
|
||||
.fcw-card { perspective: 1000px; cursor: pointer; }
|
||||
.fcw-inner {
|
||||
position: relative; transform-style: preserve-3d;
|
||||
transition: transform 0.5s cubic-bezier(.34,1.1,.64,1); min-height: 118px;
|
||||
}
|
||||
.fcw-card.flipped .fcw-inner { transform: rotateY(180deg); }
|
||||
.fcw-face {
|
||||
position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden;
|
||||
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
|
||||
border: 1.5px solid rgba(15,23,42,0.08); box-sizing: border-box;
|
||||
}
|
||||
.fcw-front { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.05)); }
|
||||
.fcw-back { background: linear-gradient(135deg, rgba(6,214,100,0.07), rgba(6,214,224,0.05)); transform: rotateY(180deg); }
|
||||
.fcw-deck { font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; color: var(--violet); }
|
||||
.fcw-back .fcw-deck { color: #059652; }
|
||||
.fcw-text { flex: 1; font-size: 0.92rem; font-weight: 600; color: var(--text); line-height: 1.35;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.fcw-hint { font-size: 0.68rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
|
||||
.fcw-hint svg { width: 12px; height: 12px; }
|
||||
.fcw-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
||||
.fcw-count { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
|
||||
.fcw-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 99px;
|
||||
border: 1.5px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.06); color: var(--violet);
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; cursor: pointer;
|
||||
transition: all 0.15s; text-decoration: none;
|
||||
}
|
||||
.fcw-btn:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
|
||||
.fcw-btn svg { width: 13px; height: 13px; stroke: currentColor; }
|
||||
.fcw-empty { text-align: center; padding: 16px 12px; color: var(--text-3); }
|
||||
.fcw-empty p { font-size: 0.82rem; margin-bottom: 10px; }
|
||||
|
||||
/* ── subjects progress bars ── */
|
||||
.subj-prog-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
@@ -1143,6 +1243,7 @@
|
||||
/* Grids <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> single column */
|
||||
.main-grid { grid-template-columns: 1fr; }
|
||||
.action-cards { grid-template-columns: 1fr; }
|
||||
.hero-row { grid-template-columns: 1fr; }
|
||||
.admin-grid { grid-template-columns: 1fr; gap: 14px; }
|
||||
.adm-actions { grid-template-columns: 1fr; gap: 10px; }
|
||||
.adm-act-group { grid-template-columns: 1fr 1fr; }
|
||||
@@ -1308,11 +1409,9 @@
|
||||
</button>
|
||||
<div class="dash-cfg-panel" id="dash-cfg-panel">
|
||||
<div class="dash-cfg-title">Показывать виджеты</div>
|
||||
<div class="dash-cfg-row" onclick="toggleDashWidget('lb-section',this)"><label>Рейтинг</label><input type="checkbox" data-widget="lb-section" checked></div>
|
||||
<div class="dash-cfg-row" onclick="toggleDashWidget('ch-section',this)"><label>Испытания недели</label><input type="checkbox" data-widget="ch-section" checked></div>
|
||||
<div class="dash-cfg-row" onclick="toggleDashWidget('stats-section',this)"><label>Статистика</label><input type="checkbox" data-widget="stats-section" checked></div>
|
||||
<div class="dash-cfg-row" onclick="toggleDashWidget('w-my-subs',this)"><label>Мои сдачи</label><input type="checkbox" data-widget="w-my-subs" checked></div>
|
||||
<div class="dash-cfg-row" onclick="toggleDashWidget('w-theory-progress',this)"><label>Теория</label><input type="checkbox" data-widget="w-theory-progress" checked></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -1348,23 +1447,64 @@
|
||||
<div class="action-banner" id="action-banner" style="display:none">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
<div class="action-cards" id="action-cards" style="display:none">
|
||||
<a class="ac-card" id="ac-continue" style="display:none" href="#">
|
||||
<span class="ac-emoji"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2V3zm20 0h-6a4 4 0 00-4 4v14a3 3 0 013-3h7V3z"/></svg></span>
|
||||
<div class="ac-body">
|
||||
<div class="ac-title" id="ac-cont-title">—</div>
|
||||
<div class="ac-sub" id="ac-cont-sub">—</div>
|
||||
<div class="hero-row" id="hero-row" style="display:none">
|
||||
|
||||
<!-- Card 1 — Continue / start reading -->
|
||||
<a class="hero-card hc-read" id="hc-read" href="/textbooks">
|
||||
<span class="hc-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z"/><path d="M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg>
|
||||
<span id="hc-read-tag">Начать чтение</span>
|
||||
</span>
|
||||
<div class="hc-h" id="hc-read-title">Учебники</div>
|
||||
<div class="hc-p" id="hc-read-sub">Открой учебник и продолжи курс с того места, где остановился.</div>
|
||||
<div class="hc-progress" id="hc-read-prog-wrap" style="display:none"><i id="hc-read-prog" style="width:0%"></i></div>
|
||||
<div class="hc-foot">
|
||||
<span class="hc-meta" id="hc-read-meta">новый учебник</span>
|
||||
<span class="hc-pct" id="hc-read-pct" style="display:none">0%</span>
|
||||
<span class="hc-btn">Начать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</div>
|
||||
<span class="ac-badge" id="ac-cont-pct"></span>
|
||||
</a>
|
||||
<a class="ac-card" id="ac-weak" style="display:none" href="#">
|
||||
<span class="ac-emoji"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></span>
|
||||
<div class="ac-body">
|
||||
<div class="ac-title" id="ac-weak-title">—</div>
|
||||
<div class="ac-sub" id="ac-weak-sub">—</div>
|
||||
|
||||
<!-- Card 2 — Lab of the day -->
|
||||
<a class="hero-card hc-lab" id="hc-lab" href="/lab">
|
||||
<div class="hc-bg" id="hc-lab-bg"></div>
|
||||
<span class="hc-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3"/><path d="M7.5 15h9"/></svg>
|
||||
Лаборатория дня
|
||||
</span>
|
||||
<div class="hc-h" id="hc-lab-title">Газовые законы</div>
|
||||
<div class="hc-p" id="hc-lab-sub">Давление, объём и температура газа.</div>
|
||||
<div class="hc-chips" id="hc-lab-chips">
|
||||
<span class="hc-chip subj" id="hc-lab-subj">Физика</span>
|
||||
<span class="hc-chip" id="hc-lab-time">~10 мин</span>
|
||||
<span class="hc-chip" id="hc-lab-level">средне</span>
|
||||
</div>
|
||||
<div class="hc-foot">
|
||||
<span class="hc-meta" id="hc-lab-meta">Освой: уравнение состояния газа</span>
|
||||
<span class="hc-btn">Открыть <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</div>
|
||||
<span class="ac-badge" id="ac-weak-pct" style="color:#E0335E"></span>
|
||||
</a>
|
||||
|
||||
<!-- Card 3 — Pet (synced with /pet module) -->
|
||||
<a class="hero-card hc-pet" id="hc-pet" href="/pet">
|
||||
<span class="hc-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="11" r="2"/><circle cx="9.5" cy="6.5" r="2"/><circle cx="14.5" cy="6.5" r="2"/><circle cx="18.5" cy="11" r="2"/><path d="M8.2 16.4C8.2 14.3 9.9 13 12 13s3.8 1.3 3.8 3.4c0 1.7-1.3 2.8-2.6 3.2-.8.2-1.6.2-2.4 0-1.3-.4-2.6-1.5-2.6-3.2z"/></svg>
|
||||
Питомец
|
||||
</span>
|
||||
<div class="hc-pet-top">
|
||||
<div class="hc-pet-name" id="hc-pet-name">Квантик</div>
|
||||
<div class="hc-pet-art" id="hc-pet-art"></div>
|
||||
</div>
|
||||
<div class="hc-xp-row"><span>Ур. <b id="hc-pet-lvl">1</b></span><span><b id="hc-pet-xp">0</b> / <span id="hc-pet-xpmax">500</span> XP</span></div>
|
||||
<div class="hc-progress"><i id="hc-pet-prog" style="width:0%"></i></div>
|
||||
<div class="hc-pet-chips">
|
||||
<div class="hc-pchip"><b id="hc-pet-streak">0</b><span>стрик</span></div>
|
||||
<div class="hc-pchip"><b id="hc-pet-goal">0/2</b><span>цель дня</span></div>
|
||||
<div class="hc-pchip"><b id="hc-pet-mood">бодр</b><span>настроение</span></div>
|
||||
</div>
|
||||
<span class="hc-btn">Ухаживать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1428,6 +1568,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ADMIN COMMAND CENTER: full redesign overview (admin only) -->
|
||||
<div id="admin-command-center" style="display:none"></div>
|
||||
|
||||
<!-- ZONE 3: Three-Column Grid -->
|
||||
<div class="main-grid">
|
||||
<!-- Col 1: Assignments -->
|
||||
@@ -1460,30 +1603,13 @@
|
||||
|
||||
<!-- Col 3: Progress -->
|
||||
<div class="widget" id="w-progress-col">
|
||||
<!-- Combined Activity Widget (heatmap + streak calendar) -->
|
||||
<div id="w-activity" style="display:none">
|
||||
<!-- Flashcard review (random card from pool) -->
|
||||
<div id="w-flashcard" style="display:none;margin-bottom:18px">
|
||||
<div class="w-head">
|
||||
<div class="w-title">Активность</div>
|
||||
<div class="act-tabs">
|
||||
<button class="act-tab active" onclick="switchActTab('heatmap',this)">Карта</button>
|
||||
<button class="act-tab" onclick="switchActTab('calendar',this)">Месяц</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pane 1: Heatmap -->
|
||||
<div class="act-pane visible" id="act-heatmap-pane">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:6px">
|
||||
<div class="act-scale-btns" id="hm-scale-btns">
|
||||
<button class="act-scale-btn" onclick="setHmScale(6,this)">6н</button>
|
||||
<button class="act-scale-btn active" onclick="setHmScale(12,this)">12н</button>
|
||||
<button class="act-scale-btn" onclick="setHmScale(26,this)">6м</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="activity-heatmap"></div>
|
||||
</div>
|
||||
<!-- Pane 2: Streak calendar -->
|
||||
<div class="act-pane" id="act-cal-pane">
|
||||
<div id="streak-cal-body"></div>
|
||||
<div class="w-title">Повтори карточку</div>
|
||||
<a class="w-more" href="/flashcards">Все карточки</a>
|
||||
</div>
|
||||
<div id="fcw-body"></div>
|
||||
</div>
|
||||
<!-- Day popup (floating) -->
|
||||
<div class="hm-day-popup" id="hm-day-popup" style="display:none"></div>
|
||||
@@ -1503,42 +1629,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My submissions compact widget (student only) -->
|
||||
<div class="full-row" id="w-my-subs" style="display:none">
|
||||
<div class="widget">
|
||||
<!-- ZONE 4: Bottom row — Activity · My submissions · Challenges -->
|
||||
<div class="bottom-grid">
|
||||
|
||||
<!-- Combined Activity Widget (heatmap + streak calendar) -->
|
||||
<div class="widget" id="w-activity" style="display:none">
|
||||
<div class="w-head">
|
||||
<div class="w-title">Активность</div>
|
||||
<div class="act-tabs">
|
||||
<button class="act-tab active" onclick="switchActTab('heatmap',this)">Карта</button>
|
||||
<button class="act-tab" onclick="switchActTab('calendar',this)">Месяц</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pane 1: Heatmap -->
|
||||
<div class="act-pane visible" id="act-heatmap-pane">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:6px">
|
||||
<div class="act-scale-btns" id="hm-scale-btns">
|
||||
<button class="act-scale-btn" onclick="setHmScale(6,this)">6н</button>
|
||||
<button class="act-scale-btn active" onclick="setHmScale(12,this)">12н</button>
|
||||
<button class="act-scale-btn" onclick="setHmScale(26,this)">6м</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="activity-heatmap"></div>
|
||||
</div>
|
||||
<!-- Pane 2: Streak calendar -->
|
||||
<div class="act-pane" id="act-cal-pane">
|
||||
<div id="streak-cal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My submissions compact widget (student only) -->
|
||||
<div class="widget" id="w-my-subs" style="display:none">
|
||||
<div class="w-head">
|
||||
<div class="w-title">Мои сдачи</div>
|
||||
<a class="ae-btn-submit" href="/homework" style="text-decoration:none">Загрузить работу</a>
|
||||
</div>
|
||||
<div class="my-subs-list" id="my-subs-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard (students only) -->
|
||||
<div class="full-row" id="lb-section" style="display:none">
|
||||
<div class="lb-widget">
|
||||
<div class="lb-head">
|
||||
<div class="lb-title" id="lb-title">Рейтинг</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select class="lb-class-sel" id="lb-class-sel" onchange="loadLeaderboard()">
|
||||
<option value="">Все</option>
|
||||
</select>
|
||||
<div class="lb-tabs">
|
||||
<button class="lb-tab active" onclick="setLbPeriod('week',this)">Неделя</button>
|
||||
<button class="lb-tab" onclick="setLbPeriod('all',this)">Всё время</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lb-list" id="lb-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Challenges (students only) -->
|
||||
<div class="full-row" id="ch-section" style="display:none">
|
||||
<div class="widget ch-widget">
|
||||
<!-- Challenges (students only) -->
|
||||
<div class="widget ch-widget" id="ch-section" style="display:none">
|
||||
<div class="w-head"><div class="w-title" id="ch-title">Испытания недели</div></div>
|
||||
<div class="ch-list" id="ch-list"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Statistics Charts (students only) -->
|
||||
@@ -1568,14 +1702,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full width: Theory -->
|
||||
<div class="full-row">
|
||||
<div class="widget" id="w-theory-progress" style="display:none">
|
||||
<div class="w-head"><div class="w-title">Теория — в процессе</div></div>
|
||||
<div id="theory-progress-grid" class="theory-courses-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full width: Full history (hidden) -->
|
||||
<div class="full-row">
|
||||
<div class="widget" id="w-full-history" style="display:none">
|
||||
@@ -1617,7 +1743,10 @@
|
||||
<script src="/js/sound.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/pet-sprite.js"></script>
|
||||
<script src="/js/lab-previews.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="/js/dashboard-admin-center.js"></script>
|
||||
<script>
|
||||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
@@ -1634,9 +1763,17 @@
|
||||
document.getElementById('dh-greeting').innerHTML = `${gr}, <span>${esc(user?.name?.split(' ')[0] || 'Администратор')}</span>`;
|
||||
document.getElementById('dh-sub').textContent = user?.role === 'admin' ? 'Панель администратора' : 'Панель учителя';
|
||||
// teacher/admin: hide student-only widgets, show admin compact layout
|
||||
document.querySelectorAll('.action-zone,.main-grid,#w-theory-progress,.full-row').forEach(el => { if (el) el.style.display = 'none'; });
|
||||
document.getElementById('admin-actions-zone').style.display = '';
|
||||
document.getElementById('admin-grid').style.display = '';
|
||||
document.querySelectorAll('.action-zone,.main-grid,.bottom-grid,.full-row').forEach(el => { if (el) el.style.display = 'none'; });
|
||||
if (isAdmin) {
|
||||
// admin: full command center (redesign) instead of compact layout
|
||||
const dh = document.querySelector('.dash-header'); if (dh) dh.style.display = 'none';
|
||||
document.getElementById('admin-actions-zone').style.display = 'none';
|
||||
document.getElementById('admin-grid').style.display = 'none';
|
||||
document.getElementById('admin-command-center').style.display = '';
|
||||
} else {
|
||||
document.getElementById('admin-actions-zone').style.display = '';
|
||||
document.getElementById('admin-grid').style.display = '';
|
||||
}
|
||||
} else {
|
||||
// Приветствие по времени суток
|
||||
const h = new Date().getHours();
|
||||
@@ -1824,7 +1961,7 @@
|
||||
const pct = c.target > 0 ? Math.min(100, Math.round(c.progress / c.target * 100)) : 0;
|
||||
const done = c.completed;
|
||||
const claimed = c.claimed;
|
||||
const icon = done ? lci('check-circle', 20) : c.type === 'topic_tests' ? lci('book-open', 20) : c.type === 'high_score' ? lci('target', 20) : c.type === 'perfect' ? lci('diamond', 20) : lci('running', 20);
|
||||
const icon = done ? lci('check-circle', 20) : c.type === 'topic_tests' ? lci('book-open', 20) : c.type === 'high_score' ? lci('target', 20) : c.type === 'perfect' ? lci('diamond', 20) : lci('footprints', 20);
|
||||
return `<div class="ch-item${done ? ' done' : ''}${claimed ? ' claimed' : ''}">
|
||||
<div class="ch-icon">${icon}</div>
|
||||
<div class="ch-body">
|
||||
@@ -3063,41 +3200,142 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Continue reading (action card) ══════════════════════ */
|
||||
async function loadContinueWidget() {
|
||||
const card = document.getElementById('ac-continue');
|
||||
const cardsWrap = document.getElementById('action-cards');
|
||||
/* ══ HERO: Reading card — данные и цвет из блока «Учебники» ═══════ */
|
||||
// Палитра обложек учебников (зеркало .tb-cover из textbooks.html)
|
||||
const TB_COVER = {
|
||||
amber:'linear-gradient(135deg,#b45309 0%,#d97706 60%,#f59e0b 100%)',
|
||||
blue:'linear-gradient(135deg,#1e40af 0%,#2563eb 60%,#3b82f6 100%)',
|
||||
green:'linear-gradient(135deg,#047857 0%,#059669 60%,#10b981 100%)',
|
||||
violet:'linear-gradient(135deg,#6d28d9 0%,#7c3aed 60%,#9333ea 100%)',
|
||||
pink:'linear-gradient(135deg,#be185d 0%,#db2777 60%,#ec4899 100%)',
|
||||
indigo:'linear-gradient(135deg,#3730a3 0%,#4f46e5 60%,#818cf8 100%)',
|
||||
rose:'linear-gradient(135deg,#9f1239 0%,#e11d48 60%,#fb7185 100%)',
|
||||
teal:'linear-gradient(135deg,#134e4a 0%,#0d9488 60%,#14b8a6 100%)',
|
||||
cyan:'linear-gradient(135deg,#164e63 0%,#0891b2 60%,#22d3ee 100%)',
|
||||
emerald:'linear-gradient(135deg,#064e3b 0%,#059669 60%,#34d399 100%)',
|
||||
'amber-light':'linear-gradient(135deg,#92400e 0%,#d97706 60%,#fbbf24 100%)',
|
||||
sky:'linear-gradient(135deg,#0c4a6e 0%,#0284c7 60%,#7dd3fc 100%)',
|
||||
red:'linear-gradient(135deg,#7f1d1d 0%,#dc2626 60%,#f87171 100%)',
|
||||
orange:'linear-gradient(135deg,#9a3412 0%,#ea580c 60%,#fb923c 100%)',
|
||||
yellow:'linear-gradient(135deg,#854d0e 0%,#ca8a04 60%,#fde047 100%)',
|
||||
};
|
||||
function _renderReadCard(o) {
|
||||
const card = document.getElementById('hc-read');
|
||||
if (!card) return;
|
||||
if (o.href) card.href = o.href;
|
||||
if (TB_COVER[o.color]) card.style.background = TB_COVER[o.color];
|
||||
document.getElementById('hc-read-tag').textContent = o.tag;
|
||||
document.getElementById('hc-read-title').textContent = o.title;
|
||||
document.getElementById('hc-read-sub').textContent = o.sub || '';
|
||||
document.getElementById('hc-read-meta').textContent = o.meta || '';
|
||||
const pw = document.getElementById('hc-read-prog-wrap');
|
||||
const pe = document.getElementById('hc-read-pct');
|
||||
if (o.showProg) {
|
||||
if (pw) { pw.style.display = ''; document.getElementById('hc-read-prog').style.width = (o.pct || 0) + '%'; }
|
||||
if (pe) { pe.style.display = ''; pe.textContent = (o.pct || 0) + '%'; }
|
||||
} else {
|
||||
if (pw) pw.style.display = 'none';
|
||||
if (pe) pe.style.display = 'none';
|
||||
}
|
||||
}
|
||||
async function loadContinueWidget() {
|
||||
const card = document.getElementById('hc-read');
|
||||
if (!card) return;
|
||||
let books;
|
||||
try {
|
||||
const data = await LS.api('/api/courses/continue');
|
||||
if (!data || !data.courseId) { card.style.display = 'none'; return; }
|
||||
const pct = data.lessonCount > 0 ? Math.round((data.doneCount || 0) / data.lessonCount * 100) : 0;
|
||||
const href = `/lesson?course=${data.courseId}&lesson=${data.lessonId}`;
|
||||
card.href = href;
|
||||
card.style.display = '';
|
||||
if (cardsWrap) cardsWrap.style.display = '';
|
||||
document.getElementById('ac-cont-title').textContent = data.courseTitle || 'Продолжить';
|
||||
document.getElementById('ac-cont-sub').textContent = `${data.lessonTitle || ''} · ${data.doneCount || 0}/${data.lessonCount || 0} уроков`;
|
||||
document.getElementById('ac-cont-pct').textContent = pct + '%';
|
||||
} catch { card.style.display = 'none'; }
|
||||
const r = await LS.api('/api/textbooks');
|
||||
books = r && r.textbooks ? r.textbooks : [];
|
||||
} catch { return; } // нет доступа/ошибка — оставляем дефолт «Учебники»
|
||||
if (!books.length) return;
|
||||
|
||||
// Выбор учебника: тот, что в процессе (есть прочитанные §), приоритет
|
||||
// последнему открытому; иначе — первый из каталога как рекомендованный.
|
||||
const withProgress = books
|
||||
.filter(b => (b.progress && (b.progress.last_para || (b.progress.read || []).length)))
|
||||
.sort((a, b) => ((b.progress.read || []).length) - ((a.progress.read || []).length));
|
||||
const inProgress = withProgress[0];
|
||||
const b = inProgress || books[0];
|
||||
const readCount = (b.progress && b.progress.read ? b.progress.read.length : 0);
|
||||
const pct = b.para_count ? Math.round(100 * readCount / b.para_count) : 0;
|
||||
const href = (b.progress && b.progress.last_para)
|
||||
? `/textbook/${b.slug}#${b.progress.last_para}`
|
||||
: `/textbook/${b.slug}`;
|
||||
|
||||
if (inProgress) {
|
||||
_renderReadCard({
|
||||
tag: 'Продолжить чтение', href, color: b.color,
|
||||
title: b.title,
|
||||
sub: b.description || `${b.grade} класс`,
|
||||
meta: `${readCount} из ${b.para_count} § прочитано`,
|
||||
pct, showProg: true,
|
||||
});
|
||||
} else {
|
||||
_renderReadCard({
|
||||
tag: 'Начать чтение', href, color: b.color,
|
||||
title: b.title,
|
||||
sub: b.description || `${b.grade} класс`,
|
||||
meta: `${b.para_count} § · новый учебник`,
|
||||
pct: 0, showProg: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Weak topics (action card) ════════════════════════════ */
|
||||
async function loadWeakWidget() {
|
||||
const card = document.getElementById('ac-weak');
|
||||
const cardsWrap = document.getElementById('action-cards');
|
||||
/* ══ HERO: Lab of the day (deterministic daily pick) ═════════════ */
|
||||
const LAB_OF_DAY = [
|
||||
{ key:'isoprocess', href:'/lab?sim=molphys', title:'Газовые законы', sub:'Давление, объём и температура газа.', subj:'Физика', time:'~10 мин', level:'средне', goal:'уравнение состояния газа' },
|
||||
{ key:'opticsbench', href:'/lab?sim=opticsbench', title:'Оптическая скамья', sub:'Собери систему линз и проследи ход лучей.', subj:'Физика', time:'~12 мин', level:'средне', goal:'построение изображения в линзе' },
|
||||
{ key:'circuit', href:'/lab?sim=circuit', title:'Электрическая цепь', sub:'Закон Ома: ток, напряжение и сопротивление.', subj:'Физика', time:'~8 мин', level:'легко', goal:'расчёт цепи по закону Ома' },
|
||||
{ key:'pendulum', href:'/lab?sim=pendulum', title:'Математический маятник', sub:'Период колебаний и зависимость от длины.', subj:'Физика', time:'~9 мин', level:'легко', goal:'формула периода маятника' },
|
||||
{ key:'waves', href:'/lab?sim=waves', title:'Волны и колебания', sub:'Длина волны, частота и стоячие волны.', subj:'Физика', time:'~11 мин', level:'средне', goal:'связь v = λf' },
|
||||
{ key:'stereo', href:'/lab?sim=stereo', title:'Стереометрия 3D', sub:'Сечения и объёмы пространственных фигур.', subj:'Геометрия', time:'~10 мин', level:'сложно', goal:'построение сечений' },
|
||||
];
|
||||
function loadLabOfDay() {
|
||||
const card = document.getElementById('hc-lab');
|
||||
if (!card) return;
|
||||
try {
|
||||
const topics = await LS.getWeakTopics();
|
||||
if (!topics.length) { card.style.display = 'none'; return; }
|
||||
const t = topics[0];
|
||||
card.href = `/test-run?subject=${t.subject_slug}&mode=exam&count=15&topic=${t.topic_id}`;
|
||||
card.style.display = '';
|
||||
if (cardsWrap) cardsWrap.style.display = '';
|
||||
document.getElementById('ac-weak-title').textContent = t.topic;
|
||||
document.getElementById('ac-weak-sub').textContent = `${t.subject_name} · ${t.wrong} из ${t.total} неверно`;
|
||||
document.getElementById('ac-weak-pct').textContent = t.error_pct + '%';
|
||||
} catch { card.style.display = 'none'; }
|
||||
const dayIdx = Math.floor(Date.now() / 86400000) % LAB_OF_DAY.length;
|
||||
const lab = LAB_OF_DAY[dayIdx];
|
||||
card.href = lab.href;
|
||||
const bg = document.getElementById('hc-lab-bg');
|
||||
if (bg && window.LabPreviews && LabPreviews[lab.key]) bg.innerHTML = LabPreviews[lab.key];
|
||||
document.getElementById('hc-lab-title').textContent = lab.title;
|
||||
document.getElementById('hc-lab-sub').textContent = lab.sub;
|
||||
document.getElementById('hc-lab-subj').textContent = lab.subj;
|
||||
document.getElementById('hc-lab-time').textContent = lab.time;
|
||||
document.getElementById('hc-lab-level').textContent = lab.level;
|
||||
document.getElementById('hc-lab-meta').textContent = 'Освой: ' + lab.goal;
|
||||
}
|
||||
|
||||
/* ══ HERO: Pet (synced with /pet module via /api/pet + PetSprite) ═ */
|
||||
async function loadPetHero() {
|
||||
const card = document.getElementById('hc-pet');
|
||||
if (!card) return;
|
||||
let d;
|
||||
try { d = await LS.api('/api/pet'); }
|
||||
catch { card.style.display = 'none'; return; } // фича питомца выключена
|
||||
if (!d) { card.style.display = 'none'; return; }
|
||||
|
||||
// Sprite — единый рендер из pet-sprite.js (как на /pet)
|
||||
const art = document.getElementById('hc-pet-art');
|
||||
if (art && window.PetSprite) {
|
||||
art.innerHTML = PetSprite.render(d.petLevel || 1, d.mood || 'neutral', d.accessories || [], d.petColor || 'purple', d.streakCurrent || 0);
|
||||
}
|
||||
document.getElementById('hc-pet-name').textContent = d.petName || 'Квантик';
|
||||
document.getElementById('hc-pet-lvl').textContent = d.level || 1;
|
||||
|
||||
// XP — как в модуле /pet: полный XP / абсолютный порог следующего уровня
|
||||
const cur = d.xpForCurrLevel || 0;
|
||||
const next = d.xpForNextLevel; // null = макс
|
||||
const pct = next ? Math.min(100, ((d.xp - cur) / (next - cur)) * 100) : 100;
|
||||
document.getElementById('hc-pet-xp').textContent = (d.xp || 0).toLocaleString();
|
||||
document.getElementById('hc-pet-xpmax').textContent = next ? next.toLocaleString() : 'MAX';
|
||||
document.getElementById('hc-pet-prog').style.width = pct + '%';
|
||||
|
||||
// Стрик / цель дня / настроение
|
||||
document.getElementById('hc-pet-streak').textContent = d.streakCurrent || 0;
|
||||
const quests = d.quests || [];
|
||||
const doneCnt = quests.filter(q => q.done).length;
|
||||
document.getElementById('hc-pet-goal').textContent = quests.length ? `${doneCnt}/${quests.length}` : '—';
|
||||
document.getElementById('hc-pet-mood').textContent = window.PetSprite ? PetSprite.moodLabel(d.mood) : (d.mood || 'бодр');
|
||||
}
|
||||
|
||||
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
|
||||
@@ -3131,10 +3369,10 @@
|
||||
|
||||
/* ══ WIDGET: Activity heatmap (redesigned) ══════════════════════════ */
|
||||
function loadActivityWidget(rows) {
|
||||
_activityRows = rows;
|
||||
_activityRows = rows || [];
|
||||
const w = document.getElementById('w-activity');
|
||||
if (!w || !rows || !rows.length) { if (w) w.style.display = 'none'; return; }
|
||||
w.style.display = '';
|
||||
if (!w) return;
|
||||
showWidget('w-activity'); // показываем всегда (пустое состояние рисует renderHeatmap)
|
||||
renderHeatmap();
|
||||
}
|
||||
|
||||
@@ -3808,11 +4046,10 @@
|
||||
|
||||
/* ── Dashboard widget visibility ──────────────────────────────────── */
|
||||
const _DASH_WIDGETS = [
|
||||
{ id: 'lb-section', label: 'Рейтинг' },
|
||||
{ id: 'ch-section', label: 'Испытания недели' },
|
||||
{ id: 'stats-section', label: 'Статистика' },
|
||||
{ id: 'w-my-subs', label: 'Мои сдачи' },
|
||||
{ id: 'w-theory-progress',label: 'Теория' },
|
||||
{ id: 'w-activity', label: 'Активность' },
|
||||
];
|
||||
|
||||
async function applyDashboardPrefs() {
|
||||
@@ -3884,12 +4121,82 @@
|
||||
loadActivityWidget(rows);
|
||||
loadSubjProgressWidget(rows);
|
||||
renderStreakCalendar(rows);
|
||||
} catch {}
|
||||
} catch { loadActivityWidget([]); }
|
||||
const heroRow = document.getElementById('hero-row');
|
||||
if (heroRow) heroRow.style.display = '';
|
||||
loadContinueWidget();
|
||||
loadWeakWidget();
|
||||
loadTheoryWidget();
|
||||
loadLabOfDay();
|
||||
loadPetHero();
|
||||
loadFlashcardWidget();
|
||||
}
|
||||
|
||||
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
||||
let _fcwTotal = 0;
|
||||
async function loadFlashcardWidget() {
|
||||
if (isTeacher) return;
|
||||
const w = document.getElementById('w-flashcard');
|
||||
if (!w || w.dataset.cfgHidden) return;
|
||||
try {
|
||||
const r = await LS.api('/api/flashcards/random');
|
||||
renderFlashcardWidget(r);
|
||||
w.style.display = '';
|
||||
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
||||
}
|
||||
|
||||
function renderFlashcardWidget(r) {
|
||||
const body = document.getElementById('fcw-body');
|
||||
if (!body) return;
|
||||
if (!r || !r.card) {
|
||||
_fcwTotal = 0;
|
||||
body.innerHTML = `<div class="fcw-empty">
|
||||
<p>Пока нет карточек. Создавай их в любой точке системы кнопкой внизу справа или на странице карточек.</p>
|
||||
<a class="fcw-btn" href="/flashcards">
|
||||
${lci('plus','width:13px;height:13px')} Создать карточку
|
||||
</a>
|
||||
</div>`;
|
||||
reIcons();
|
||||
return;
|
||||
}
|
||||
_fcwTotal = r.total || 0;
|
||||
const c = r.card;
|
||||
const back = (c.back || '').trim() || '(ответ не заполнен)';
|
||||
const col = c.deck_color || '#9B5DE5';
|
||||
body.innerHTML = `
|
||||
<div class="fcw-card" onclick="fcwFlip(this)">
|
||||
<div class="fcw-inner">
|
||||
<div class="fcw-face fcw-front">
|
||||
<div class="fcw-deck" style="color:${esc(col)}">${esc(c.deck_title || 'Карточка')}</div>
|
||||
<div class="fcw-text">${esc(c.front)}</div>
|
||||
<div class="fcw-hint">${lci('rotate-cw','width:12px;height:12px')} нажми, чтобы перевернуть</div>
|
||||
</div>
|
||||
<div class="fcw-face fcw-back">
|
||||
<div class="fcw-deck">Ответ</div>
|
||||
<div class="fcw-text">${esc(back)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fcw-actions">
|
||||
<button class="fcw-btn" type="button" onclick="fcwNext(event)">
|
||||
${lci('shuffle','width:13px;height:13px')} Другая
|
||||
</button>
|
||||
<span class="fcw-count">${_fcwTotal} ${_fcwTotal === 1 ? 'карточка' : (_fcwTotal % 10 >= 2 && _fcwTotal % 10 <= 4 && (_fcwTotal % 100 < 12 || _fcwTotal % 100 > 14) ? 'карточки' : 'карточек')} в пуле</span>
|
||||
</div>`;
|
||||
reIcons();
|
||||
}
|
||||
|
||||
function fcwFlip(el) { el.classList.toggle('flipped'); }
|
||||
|
||||
async function fcwNext(e) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const r = await LS.api('/api/flashcards/random');
|
||||
renderFlashcardWidget(r);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Обновлять виджет, когда карточку добавили через глобальный FAB
|
||||
window.addEventListener('flashcard:added', () => { if (!isTeacher) loadFlashcardWidget(); });
|
||||
|
||||
/* ══ INIT ═════════════════════════════════════════════════════════════ */
|
||||
window.addEventListener('pageshow', e => { if (e.persisted) location.reload(); });
|
||||
|
||||
@@ -3989,8 +4296,12 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (isTeacher) {
|
||||
// Admin/Teacher: compact admin layout
|
||||
if (isAdmin) {
|
||||
// Admin: command center (redesign) on real /api/admin/overview data
|
||||
const ccEl = document.getElementById('admin-command-center');
|
||||
if (ccEl && window.DashAdminCenter) DashAdminCenter.mount(ccEl);
|
||||
} else if (isTeacher) {
|
||||
// Teacher: compact admin layout
|
||||
loadAdminStats();
|
||||
loadTeacherKPIs();
|
||||
loadAdminAssignments();
|
||||
@@ -4002,8 +4313,6 @@
|
||||
loadAssignments();
|
||||
loadStats();
|
||||
loadGamification();
|
||||
_populateLbClasses();
|
||||
loadLeaderboard();
|
||||
loadChallenges();
|
||||
loadStudentWidgets();
|
||||
loadDashboardStats();
|
||||
|
||||
+205
-51
@@ -80,6 +80,14 @@
|
||||
.card-item { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
|
||||
display: flex; gap: 0; overflow: hidden; }
|
||||
.card-item.editing { border-color: var(--violet); }
|
||||
.card-item.dragging { opacity: .45; }
|
||||
.card-item.drag-over-top { box-shadow: inset 0 3px 0 0 var(--violet); }
|
||||
.card-item.drag-over-bottom { box-shadow: inset 0 -3px 0 0 var(--violet); }
|
||||
.card-drag { display: flex; align-items: center; padding: 0 6px; cursor: grab;
|
||||
color: var(--text-3); flex-shrink: 0; border-right: 1px solid var(--border); }
|
||||
.card-drag:active { cursor: grabbing; }
|
||||
.card-drag:hover { color: var(--violet); background: var(--surface-2); }
|
||||
.card-drag .ic { width: 18px; height: 18px; }
|
||||
.card-side { flex: 1; padding: 12px 14px; min-width: 0; }
|
||||
.card-divider { width: 1px; background: var(--border); flex-shrink: 0; }
|
||||
.card-side-lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase;
|
||||
@@ -101,6 +109,15 @@
|
||||
color: var(--text); outline: none; transition: .15s; }
|
||||
.card-add-input:focus { border-color: var(--violet); }
|
||||
|
||||
/* card search */
|
||||
.card-search-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
|
||||
background: #fff; border: 1.5px solid var(--border); border-radius: 10px; padding: 0 12px; }
|
||||
.card-search-bar:focus-within { border-color: var(--violet); }
|
||||
.card-search-ic { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
|
||||
.card-search-input { flex: 1; border: none; outline: none; background: transparent; padding: 9px 0;
|
||||
font-family: 'Manrope', sans-serif; font-size: .86rem; color: var(--text); }
|
||||
.card-search-count { font-size: .72rem; color: var(--text-3); font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
/* ── study mode ── */
|
||||
#view-study { display: none; }
|
||||
.study-wrap { max-width: 600px; margin: 0 auto; }
|
||||
@@ -145,7 +162,12 @@
|
||||
.sq-btn { padding: 10px 20px; border-radius: 10px; border: 2px solid transparent;
|
||||
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .84rem; font-weight: 700;
|
||||
transition: .18s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.sq-btn .sq-top { display: flex; align-items: center; gap: 6px; }
|
||||
.sq-btn .sq-days { font-size: .66rem; font-weight: 600; opacity: .65; }
|
||||
.fc-kbd { font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 800; line-height: 1;
|
||||
padding: 2px 5px; border-radius: 5px; background: rgba(0,0,0,.08);
|
||||
border: 1px solid rgba(0,0,0,.12); color: inherit; opacity: .8; }
|
||||
.study-hint .fc-kbd { opacity: .9; }
|
||||
.sq-btn-again { background: #FEE2E2; border-color: #FECACA; color: #DC2626; }
|
||||
.sq-btn-again:hover { background: #FECACA; }
|
||||
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
|
||||
@@ -253,6 +275,11 @@
|
||||
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
|
||||
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
||||
</div>
|
||||
<div class="card-search-bar" id="card-search-bar" style="display:none">
|
||||
<svg class="ic card-search-ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input class="card-search-input" id="card-search" placeholder="Поиск по карточкам…" oninput="onCardSearch(this.value)" />
|
||||
<span class="card-search-count" id="card-search-count"></span>
|
||||
</div>
|
||||
<div class="card-list" id="card-list"></div>
|
||||
<!-- Add card row -->
|
||||
<div class="card-add-bar" style="margin-bottom:14px">
|
||||
@@ -293,13 +320,13 @@
|
||||
<span class="swipe-indicator swipe-left-ind" id="ind-left">ЕЩЁ РАЗ <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="study-hint" id="study-flip-hint">Нажмите, чтобы увидеть ответ</div>
|
||||
<div class="study-hint" id="study-flip-hint">Нажмите или <kbd class="fc-kbd">Пробел</kbd>, чтобы увидеть ответ</div>
|
||||
|
||||
<div class="study-btns" id="study-btns">
|
||||
<button class="sq-btn sq-btn-again" onclick="answer(0)">Снова<span class="sq-days" id="sq-days-0"><1 мин</span></button>
|
||||
<button class="sq-btn sq-btn-hard" onclick="answer(3)">Трудно<span class="sq-days" id="sq-days-3">—</span></button>
|
||||
<button class="sq-btn sq-btn-good" onclick="answer(4)">Знаю<span class="sq-days" id="sq-days-4">—</span></button>
|
||||
<button class="sq-btn sq-btn-easy" onclick="answer(5)">Легко<span class="sq-days" id="sq-days-5">—</span></button>
|
||||
<button class="sq-btn sq-btn-again" onclick="answer(0)"><span class="sq-top">Снова<kbd class="fc-kbd">1</kbd></span><span class="sq-days" id="sq-days-0">1 день</span></button>
|
||||
<button class="sq-btn sq-btn-hard" onclick="answer(3)"><span class="sq-top">Трудно<kbd class="fc-kbd">2</kbd></span><span class="sq-days" id="sq-days-3">—</span></button>
|
||||
<button class="sq-btn sq-btn-good" onclick="answer(4)"><span class="sq-top">Знаю<kbd class="fc-kbd">3</kbd></span><span class="sq-days" id="sq-days-4">—</span></button>
|
||||
<button class="sq-btn sq-btn-easy" onclick="answer(5)"><span class="sq-top">Легко<kbd class="fc-kbd">4</kbd></span><span class="sq-days" id="sq-days-5">—</span></button>
|
||||
</div>
|
||||
|
||||
<!-- done screen -->
|
||||
@@ -371,15 +398,24 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script>
|
||||
/* ════ Constants & State ════ */
|
||||
const COLORS = ['#9B5DE5','#EF476F','#FF9F1C','#06D6E0','#22d399','#3B82F6','#F15BB5','#6B7280'];
|
||||
|
||||
let _decks = [];
|
||||
let _curDeck = null;
|
||||
let _cards = [];
|
||||
let _editingDeckId = null;
|
||||
let _deckColor = '#9B5DE5';
|
||||
let _cardFilter = '';
|
||||
|
||||
(async () => {
|
||||
/* ── auth ── */
|
||||
const user = await LS.init();
|
||||
const { user } = LS.initPage();
|
||||
if (!user) return;
|
||||
const avatarEl = document.getElementById('nav-avatar');
|
||||
const nameEl = document.getElementById('nav-user');
|
||||
const initials = (user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
if (avatarEl) avatarEl.textContent = initials;
|
||||
if (nameEl) nameEl.textContent = user.name || user.username || '';
|
||||
LS.renderNavAvatar(avatarEl, user);
|
||||
if (nameEl) nameEl.textContent = user.name || '';
|
||||
LS.showBoardIfAllowed();
|
||||
if (user.role!=='student') { document.getElementById('btn-classes')?.style && (document.getElementById('btn-classes').style.display='flex'); }
|
||||
if (user.role==='admin') { document.getElementById('btn-admin')?.style && (document.getElementById('btn-admin').style.display='flex'); }
|
||||
@@ -391,25 +427,40 @@
|
||||
init();
|
||||
})();
|
||||
|
||||
/* ════ State ════ */
|
||||
let _decks = [];
|
||||
let _curDeck = null; // { id, title, color, ... }
|
||||
let _cards = []; // cards in current deck
|
||||
let _editingDeckId = null;
|
||||
let _deckColor = '#9B5DE5';
|
||||
|
||||
const COLORS = ['#9B5DE5','#EF476F','#FF9F1C','#06D6E0','#22d399','#3B82F6','#F15BB5','#6B7280'];
|
||||
|
||||
/* ════ Init ════ */
|
||||
async function init() {
|
||||
buildColorPicker();
|
||||
bindStudyKeys();
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
/* ── keyboard shortcuts in study mode ──
|
||||
Space/Enter/↑/↓ — перевернуть; после переворота 1-4 или ←/→ — оценка. */
|
||||
function bindStudyKeys() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (document.getElementById('view-study')?.style.display !== 'block') return;
|
||||
if (document.getElementById('study-done')?.style.display === 'block') return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
|
||||
const flip = ['Space', 'Enter', 'ArrowUp', 'ArrowDown'];
|
||||
if (!_studyFlipped) {
|
||||
if (flip.includes(e.code) || e.key === ' ') { e.preventDefault(); flipCard(); }
|
||||
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { e.preventDefault(); flipCard(); }
|
||||
return;
|
||||
}
|
||||
// flipped → grade
|
||||
const map = { Digit1: 0, Digit2: 3, Digit3: 4, Digit4: 5,
|
||||
Numpad1: 0, Numpad2: 3, Numpad3: 4, Numpad4: 5,
|
||||
ArrowLeft: 0, ArrowRight: 5 };
|
||||
if (e.code in map) { e.preventDefault(); answer(map[e.code]); }
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDecks() {
|
||||
const [decks, stats] = await Promise.all([
|
||||
LS.api('/flashcards/decks').catch(()=>({decks:[]})),
|
||||
LS.api('/flashcards/stats').catch(()=>null),
|
||||
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
||||
LS.api('/api/flashcards/stats').catch(()=>null),
|
||||
]);
|
||||
_decks = decks.decks || [];
|
||||
renderStats(stats);
|
||||
@@ -443,7 +494,7 @@ function renderDecks() {
|
||||
const grid = document.getElementById('deck-grid');
|
||||
if (!_decks.length) {
|
||||
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
|
||||
<div class="fc-empty-icon">🃏</div>
|
||||
<div class="fc-empty-icon"><svg class="ic" style="width:46px;height:46px" viewBox="0 0 24 24"><rect x="3" y="5" width="13" height="15" rx="2"/><path d="M8 5V3.5A1.5 1.5 0 0 1 9.5 2h9A1.5 1.5 0 0 1 20 3.5V16a1.5 1.5 0 0 1-1.5 1.5H16"/></svg></div>
|
||||
<h3>Нет колод</h3>
|
||||
<p>Создайте первую колоду карточек</p>
|
||||
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
|
||||
@@ -484,8 +535,10 @@ async function openDeck(id) {
|
||||
_curDeck = _decks.find(d => d.id === id);
|
||||
if (!_curDeck) return;
|
||||
document.getElementById('cards-deck-title').textContent = _curDeck.title;
|
||||
const data = await LS.api(`/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
||||
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
||||
_cards = data.cards || [];
|
||||
_cardFilter = '';
|
||||
const si = document.getElementById('card-search'); if (si) si.value = '';
|
||||
renderCardList();
|
||||
document.getElementById('view-decks').style.display = 'none';
|
||||
document.getElementById('view-cards').style.display = 'block';
|
||||
@@ -512,18 +565,48 @@ function showCards() {
|
||||
}
|
||||
|
||||
/* ════ Card list ════ */
|
||||
function onCardSearch(v) {
|
||||
_cardFilter = (v || '').trim().toLowerCase();
|
||||
renderCardList();
|
||||
}
|
||||
|
||||
function renderCardList() {
|
||||
const list = document.getElementById('card-list');
|
||||
const bar = document.getElementById('card-search-bar');
|
||||
// строка поиска появляется, когда карточек достаточно для фильтрации
|
||||
if (bar) bar.style.display = _cards.length > 4 ? 'flex' : 'none';
|
||||
|
||||
if (!_cards.length) {
|
||||
if (bar) bar.style.display = 'none';
|
||||
list.innerHTML = `<div class="fc-empty" style="padding:30px 0">
|
||||
<div class="fc-empty-icon"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
|
||||
<div class="fc-empty-icon"><svg class="ic" style="width:40px;height:40px" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
|
||||
<h3>Нет карточек</h3>
|
||||
<p>Добавьте первую карточку ниже</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = _cards.map((c, i) => `
|
||||
<div class="card-item" id="ci-${c.id}">
|
||||
|
||||
const q = _cardFilter;
|
||||
const shown = q
|
||||
? _cards.filter(c => (c.front || '').toLowerCase().includes(q) || (c.back || '').toLowerCase().includes(q))
|
||||
: _cards;
|
||||
|
||||
const cnt = document.getElementById('card-search-count');
|
||||
if (cnt) cnt.textContent = q ? `${shown.length} из ${_cards.length}` : `${_cards.length} карточек`;
|
||||
|
||||
if (!shown.length) {
|
||||
list.innerHTML = `<div class="fc-empty" style="padding:24px 0">
|
||||
<h3>Ничего не найдено</h3>
|
||||
<p>По запросу «${esc(q)}» карточек нет</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = shown.map((c) => `
|
||||
<div class="card-item" id="ci-${c.id}" data-id="${c.id}">
|
||||
${q ? '' : `<div class="card-drag" title="Перетащите для сортировки">
|
||||
<svg class="ic" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>
|
||||
</div>`}
|
||||
<div class="card-side">
|
||||
<div class="card-side-lbl">Вопрос</div>
|
||||
<textarea class="card-textarea" rows="2"
|
||||
@@ -541,6 +624,71 @@ function renderCardList() {
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
if (!q) bindCardDrag();
|
||||
}
|
||||
|
||||
/* ── drag-reorder карточек (только без активного фильтра) ── */
|
||||
let _dragId = null;
|
||||
function bindCardDrag() {
|
||||
const list = document.getElementById('card-list');
|
||||
if (!list) return;
|
||||
list.querySelectorAll('.card-item').forEach(el => {
|
||||
const handle = el.querySelector('.card-drag');
|
||||
if (!handle) return;
|
||||
// перетаскивание стартует только с ручки — textarea остаётся редактируемой
|
||||
handle.addEventListener('mousedown', () => el.setAttribute('draggable', 'true'));
|
||||
handle.addEventListener('touchstart', () => el.setAttribute('draggable', 'true'), { passive: true });
|
||||
|
||||
el.addEventListener('dragstart', (e) => {
|
||||
_dragId = +el.dataset.id;
|
||||
el.classList.add('dragging');
|
||||
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(_dragId)); } catch {}
|
||||
});
|
||||
el.addEventListener('dragend', () => {
|
||||
el.classList.remove('dragging'); el.removeAttribute('draggable');
|
||||
list.querySelectorAll('.drag-over-top,.drag-over-bottom')
|
||||
.forEach(x => x.classList.remove('drag-over-top', 'drag-over-bottom'));
|
||||
_dragId = null;
|
||||
});
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
const r = el.getBoundingClientRect();
|
||||
const after = (e.clientY - r.top) > r.height / 2;
|
||||
el.classList.toggle('drag-over-bottom', after);
|
||||
el.classList.toggle('drag-over-top', !after);
|
||||
});
|
||||
el.addEventListener('dragleave', () => el.classList.remove('drag-over-top', 'drag-over-bottom'));
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.remove('drag-over-top', 'drag-over-bottom');
|
||||
const targetId = +el.dataset.id;
|
||||
if (_dragId == null || _dragId === targetId) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const after = (e.clientY - r.top) > r.height / 2;
|
||||
moveCard(_dragId, targetId, after);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function moveCard(dragId, targetId, after) {
|
||||
const from = _cards.findIndex(c => c.id === dragId);
|
||||
if (from < 0) return;
|
||||
const item = _cards.splice(from, 1)[0];
|
||||
let to = _cards.findIndex(c => c.id === targetId);
|
||||
if (to < 0) { _cards.splice(from, 0, item); return; }
|
||||
if (after) to += 1;
|
||||
_cards.splice(to, 0, item);
|
||||
renderCardList();
|
||||
persistCardOrder();
|
||||
}
|
||||
|
||||
async function persistCardOrder() {
|
||||
if (!_curDeck) return;
|
||||
const order = _cards.map(c => c.id);
|
||||
await LS.api(`/api/flashcards/decks/${_curDeck.id}/reorder`, {
|
||||
method: 'PUT', body: JSON.stringify({ order })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function addCard() {
|
||||
@@ -548,7 +696,7 @@ async function addCard() {
|
||||
const front = document.getElementById('new-card-front').value.trim();
|
||||
const back = document.getElementById('new-card-back').value.trim();
|
||||
if (!front && !back) return;
|
||||
const card = await LS.api(`/flashcards/decks/${_curDeck.id}/cards`, {
|
||||
const card = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards`, {
|
||||
method: 'POST', body: JSON.stringify({ front, back })
|
||||
}).catch(()=>null);
|
||||
if (!card) return;
|
||||
@@ -567,14 +715,14 @@ async function saveCard(id, field, value) {
|
||||
const card = _cards.find(c => c.id === id);
|
||||
if (!card) return;
|
||||
card[field] = value;
|
||||
await LS.api(`/flashcards/cards/${id}`, {
|
||||
await LS.api(`/api/flashcards/cards/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify({ [field]: value })
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
async function deleteCard(id) {
|
||||
if (!confirm('Удалить карточку?')) return;
|
||||
await LS.api(`/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
|
||||
if (!await LS.confirm('Удалить карточку?', { title: 'Удаление карточки', confirmText: 'Удалить', danger: true })) return;
|
||||
await LS.api(`/api/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
|
||||
_cards = _cards.filter(c => c.id !== id);
|
||||
renderCardList();
|
||||
}
|
||||
@@ -595,7 +743,7 @@ async function saveBulk() {
|
||||
return { front: (front||'').trim(), back: rest.join(sep).trim() };
|
||||
}).filter(c => c.front);
|
||||
if (!cards.length) return;
|
||||
const result = await LS.api(`/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
||||
const result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
||||
method: 'POST', body: JSON.stringify({ cards })
|
||||
}).catch(()=>null);
|
||||
if (result?.inserted) {
|
||||
@@ -619,7 +767,7 @@ async function startStudy() {
|
||||
async function startStudyForDeck(deckId) {
|
||||
_curDeck = _curDeck || _decks.find(d => d.id === deckId);
|
||||
if (!_curDeck) return;
|
||||
const data = await LS.api(`/flashcards/decks/${deckId}/study`).catch(()=>null);
|
||||
const data = await LS.api(`/api/flashcards/decks/${deckId}/study`).catch(()=>null);
|
||||
if (!data || !data.cards?.length) {
|
||||
LS.toast('Нет карточек для повторения — всё актуально!', 'success');
|
||||
return;
|
||||
@@ -694,7 +842,7 @@ async function answer(quality) {
|
||||
else if (quality === 4) _sessionStats.good++;
|
||||
else if (quality === 5) _sessionStats.easy++;
|
||||
// send review
|
||||
await LS.api(`/flashcards/cards/${card.id}/review`, {
|
||||
await LS.api(`/api/flashcards/cards/${card.id}/review`, {
|
||||
method: 'POST', body: JSON.stringify({ quality })
|
||||
}).catch(()=>{});
|
||||
// animate swipe
|
||||
@@ -727,25 +875,31 @@ function finishStudy() {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
/* ── estimated next interval preview for sq buttons ── */
|
||||
function updateSQDays(card) {
|
||||
/* ── estimated next interval preview for sq buttons ──
|
||||
ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе
|
||||
превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q
|
||||
(q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых
|
||||
повторениях дают одинаковый интервал — это корректно.
|
||||
(Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */
|
||||
function fcNextInterval(card, q) {
|
||||
const ef = card.ease_factor || 2.5;
|
||||
const iv = card.interval_days || 1;
|
||||
const rep = card.repetitions || 0;
|
||||
const preview = (q) => {
|
||||
if (q < 3) return '<1 мин';
|
||||
let niv;
|
||||
if (rep === 0) niv = 1;
|
||||
else if (rep === 1) niv = 6;
|
||||
else niv = Math.round(iv * ef);
|
||||
const nef = Math.max(1.3, ef + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
|
||||
let n2 = (q === 3 ? Math.max(1, niv - 2) : q === 4 ? niv : Math.round(niv * nef));
|
||||
return n2 <= 1 ? '1 день' : n2 + ' дн.';
|
||||
};
|
||||
document.getElementById('sq-days-0').textContent = '<1 мин';
|
||||
document.getElementById('sq-days-3').textContent = preview(3);
|
||||
document.getElementById('sq-days-4').textContent = preview(4);
|
||||
document.getElementById('sq-days-5').textContent = preview(5);
|
||||
if (q < 3) return 1;
|
||||
if (rep === 0) return 1;
|
||||
if (rep === 1) return 6;
|
||||
return Math.round(iv * ef);
|
||||
}
|
||||
function fcDaysLabel(n) {
|
||||
if (n <= 1) return '1 день';
|
||||
if (n < 5) return n + ' дня';
|
||||
return n + ' дн.';
|
||||
}
|
||||
function updateSQDays(card) {
|
||||
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
|
||||
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
|
||||
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
|
||||
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
|
||||
}
|
||||
|
||||
/* ── touch/mouse swipe ── */
|
||||
@@ -827,7 +981,7 @@ async function saveDeckModal() {
|
||||
const desc = document.getElementById('modal-deck-desc').value.trim();
|
||||
if (!title) { document.getElementById('modal-deck-name').focus(); return; }
|
||||
if (_editingDeckId) {
|
||||
await LS.api(`/flashcards/decks/${_editingDeckId}`, {
|
||||
await LS.api(`/api/flashcards/decks/${_editingDeckId}`, {
|
||||
method: 'PUT', body: JSON.stringify({ title, description: desc, color: _deckColor })
|
||||
}).catch(()=>{});
|
||||
_curDeck.title = title; _curDeck.description = desc; _curDeck.color = _deckColor;
|
||||
@@ -835,7 +989,7 @@ async function saveDeckModal() {
|
||||
const d = _decks.find(x => x.id === _editingDeckId);
|
||||
if (d) { d.title = title; d.description = desc; d.color = _deckColor; }
|
||||
} else {
|
||||
const deck = await LS.api('/flashcards/decks', {
|
||||
const deck = await LS.api('/api/flashcards/decks', {
|
||||
method: 'POST', body: JSON.stringify({ title, description: desc, color: _deckColor })
|
||||
}).catch(()=>null);
|
||||
if (deck) { _decks.unshift(deck); renderDecks(); }
|
||||
@@ -846,7 +1000,7 @@ async function saveDeckModal() {
|
||||
async function confirmDeleteDeck() {
|
||||
if (!_curDeck) return;
|
||||
if (!await LS.confirm(`Удалить колоду «${_curDeck.title}» и все карточки?`, { title: 'Удаление колоды', confirmText: 'Удалить', danger: true })) return;
|
||||
await LS.api(`/flashcards/decks/${_curDeck.id}`, { method: 'DELETE' }).catch(()=>{});
|
||||
await LS.api(`/api/flashcards/decks/${_curDeck.id}`, { method: 'DELETE' }).catch(()=>{});
|
||||
_decks = _decks.filter(d => d.id !== _curDeck.id);
|
||||
_curDeck = null; _cards = [];
|
||||
showDecks();
|
||||
|
||||
@@ -30,9 +30,13 @@
|
||||
|
||||
/* ─── KaTeX rendering ─── */
|
||||
const KATEX_OPTS = {
|
||||
// Порядок важен: многосимвольные/display-разделители ($$, \[) идут раньше
|
||||
// одиночного $, иначе auto-render распознает $$ как два пустых $.
|
||||
delimiters: [
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '$', right: '$', display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
};
|
||||
|
||||
@@ -1,42 +1,93 @@
|
||||
'use strict';
|
||||
/* admin → gam (gamification) section: stats + top + recent XP + purchases + award XP */
|
||||
/* admin → gam (gamification) section: stats + top + recent XP + purchases + award XP/coins */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
let _gamSearchTimer = null;
|
||||
let _gamAwarding = false;
|
||||
let _allUsers = []; // кэш пользователей для <select>-ов
|
||||
|
||||
const XP_REASONS = {
|
||||
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
|
||||
'correct_answers':['check-circle', '#10B981', 'Правильные ответы'],
|
||||
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
|
||||
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
|
||||
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
|
||||
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
|
||||
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
|
||||
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
|
||||
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
|
||||
'correct_answers': ['check-circle', '#10B981', 'Правильные ответы'],
|
||||
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
|
||||
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
|
||||
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
|
||||
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
|
||||
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
|
||||
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
|
||||
};
|
||||
|
||||
const PRESET_REASONS = [
|
||||
'Admin award',
|
||||
'За участие в мероприятии',
|
||||
'За активность на уроке',
|
||||
'Бонус за отличную работу',
|
||||
'Поощрение за серию',
|
||||
'Компенсация (техническая)',
|
||||
];
|
||||
|
||||
function fmtXPReason(reason) {
|
||||
if (!reason) return '—';
|
||||
const entry = XP_REASONS[reason];
|
||||
if (entry) {
|
||||
const [icon, color, label] = entry;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon,14)}</span>${label}</span>`;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon, 14)}</span>${label}</span>`;
|
||||
}
|
||||
if (reason.startsWith('achievement:')) {
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award',14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award', 14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
|
||||
}
|
||||
if (reason.startsWith('Испытание:')) {
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords',14)}</span>${esc(reason)}</span>`;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords', 14)}</span>${esc(reason)}</span>`;
|
||||
}
|
||||
return esc(reason);
|
||||
}
|
||||
|
||||
/* ── Загрузить всех пользователей для select-ов ─────────────────────── */
|
||||
async function loadAllUsers() {
|
||||
if (_allUsers.length) return _allUsers;
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ limit: 500 });
|
||||
_allUsers = (r.users || []).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'ru'));
|
||||
} catch (e) { _allUsers = []; }
|
||||
return _allUsers;
|
||||
}
|
||||
|
||||
function buildUserOptions(selected) {
|
||||
const blank = `<option value="">— Выберите пользователя —</option>`;
|
||||
return blank + _allUsers.map(u => {
|
||||
const label = (u.name || u.email || 'ID:' + u.id) + (u.role !== 'student' ? ' (' + u.role + ')' : '');
|
||||
const sel = selected && selected == u.id ? ' selected' : '';
|
||||
return `<option value="${u.id}"${sel}>${esc(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateSelects() {
|
||||
for (const id of ['gam-award-uid', 'gam-reset-uid']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = buildUserOptions(el.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Быстрый фильтр select-а по вводу ───────────────────────────────── */
|
||||
function filterUserSelect(q, selectId) {
|
||||
const el = document.getElementById(selectId);
|
||||
if (!el) return;
|
||||
const ql = q.toLowerCase();
|
||||
for (const opt of el.options) {
|
||||
if (!opt.value) continue;
|
||||
opt.style.display = !ql || opt.textContent.toLowerCase().includes(ql) ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Статистика ──────────────────────────────────────────────────────── */
|
||||
async function load() {
|
||||
const { fmtDate } = AdminCtx;
|
||||
try {
|
||||
const stats = await LS.adminGamStats();
|
||||
const [stats] = await Promise.all([
|
||||
LS.adminGamStats(),
|
||||
loadAllUsers(),
|
||||
]);
|
||||
|
||||
document.getElementById('gam-stats-grid').innerHTML = `
|
||||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="zap" class="stat-icon"></i></div>
|
||||
@@ -66,116 +117,122 @@
|
||||
|
||||
// Top-10
|
||||
const topBody = document.getElementById('gam-top-body');
|
||||
if (stats.topByXP?.length) {
|
||||
topBody.innerHTML = stats.topByXP.slice(0, 10).map((u, i) => `<tr>
|
||||
<td><strong>${i + 1}</strong></td>
|
||||
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
|
||||
<td>${u.level}</td>
|
||||
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
topBody.innerHTML = '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
|
||||
}
|
||||
topBody.innerHTML = stats.topByXP?.length
|
||||
? stats.topByXP.slice(0, 10).map((u, i) => `<tr>
|
||||
<td><strong>${i + 1}</strong></td>
|
||||
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
|
||||
<td>${u.level}</td>
|
||||
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
|
||||
|
||||
// Recent XP
|
||||
// Recent XP log
|
||||
const logBody = document.getElementById('gam-log-body');
|
||||
if (stats.recentXP?.length) {
|
||||
logBody.innerHTML = stats.recentXP.slice(0, 20).map(e => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
|
||||
<td>${esc(e.name || e.user_name || '—')}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
logBody.innerHTML = '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
|
||||
}
|
||||
logBody.innerHTML = stats.recentXP?.length
|
||||
? stats.recentXP.slice(0, 20).map(e => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
|
||||
<td>${esc(e.name || e.user_name || '—')}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
|
||||
|
||||
// Purchases
|
||||
const purchBody = document.getElementById('gam-purchases-body');
|
||||
if (stats.recentPurchases?.length) {
|
||||
purchBody.innerHTML = stats.recentPurchases.slice(0, 20).map(p => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
|
||||
<td>${esc(p.user_name || '—')}</td>
|
||||
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
|
||||
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
|
||||
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
purchBody.innerHTML = '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
|
||||
}
|
||||
purchBody.innerHTML = stats.recentPurchases?.length
|
||||
? stats.recentPurchases.slice(0, 20).map(p => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
|
||||
<td>${esc(p.user_name || '—')}</td>
|
||||
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
|
||||
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
|
||||
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
|
||||
|
||||
populateSelects();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
document.getElementById('gam-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function gamSearchUser(q, prefix) {
|
||||
clearTimeout(_gamSearchTimer);
|
||||
const box = document.getElementById(prefix + '-results');
|
||||
if (q.length < 2) { box.classList.remove('open'); return; }
|
||||
_gamSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ q, limit: 8 });
|
||||
const label = u => u.name || u.email;
|
||||
box.innerHTML = (r.users || []).map(u => `<div class="us-item" data-uid="${u.id}" data-name="${esc(label(u))}" data-prefix="${esc(prefix)}" onclick="gamPickUser(this)">
|
||||
<span>${esc(label(u))}</span><span class="us-role">${esc(u.role)}</span>
|
||||
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
|
||||
box.classList.add('open');
|
||||
} catch(e) { box.classList.remove('open'); }
|
||||
}, 300);
|
||||
/* ── Пресеты XP ──────────────────────────────────────────────────────── */
|
||||
function gamSetXP(val) {
|
||||
const inp = document.getElementById('gam-award-xp');
|
||||
if (!inp) return;
|
||||
inp.value = val;
|
||||
inp.readOnly = false;
|
||||
document.querySelectorAll('.gam-xp-preset').forEach(b => b.classList.toggle('active', b.dataset.xp == val));
|
||||
}
|
||||
|
||||
function gamPickUser(el) {
|
||||
const prefix = el.dataset.prefix;
|
||||
document.getElementById(prefix + '-uid').value = el.dataset.uid;
|
||||
document.getElementById(prefix + '-user').value = el.dataset.name || '';
|
||||
document.getElementById(prefix + '-results').classList.remove('open');
|
||||
function gamSetCoins(val) {
|
||||
const inp = document.getElementById('gam-award-coins');
|
||||
if (!inp) return;
|
||||
inp.value = val;
|
||||
document.querySelectorAll('.gam-coins-preset').forEach(b => b.classList.toggle('active', b.dataset.coins == val));
|
||||
}
|
||||
|
||||
function gamSetReason(val) {
|
||||
const inp = document.getElementById('gam-award-reason');
|
||||
if (inp) inp.value = val;
|
||||
}
|
||||
|
||||
/* ── Начислить XP/Монеты ─────────────────────────────────────────────── */
|
||||
async function gamAdminAward() {
|
||||
if (_gamAwarding) return;
|
||||
const userId = parseInt(document.getElementById('gam-award-uid').value);
|
||||
const xp = parseInt(document.getElementById('gam-award-xp').value) || 0;
|
||||
const coins = parseInt(document.getElementById('gam-award-coins').value) || 0;
|
||||
const reason = document.getElementById('gam-award-reason').value.trim();
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (!xp && !coins) { LS.toast('Введите XP или монеты', 'error'); return; }
|
||||
const userId = parseInt(document.getElementById('gam-award-uid').value, 10);
|
||||
const xpRaw = document.getElementById('gam-award-xp').value;
|
||||
const coinsRaw = document.getElementById('gam-award-coins').value;
|
||||
const xp = xpRaw === '' ? 0 : Number(xpRaw);
|
||||
const coins = coinsRaw === '' ? 0 : Number(coinsRaw);
|
||||
const reason = document.getElementById('gam-award-reason').value.trim() || 'Admin award';
|
||||
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (xp < 0 || coins < 0) { LS.toast('Значения не могут быть отрицательными', 'error'); return; }
|
||||
if (!xp && !coins) { LS.toast('Введите XP или монеты (больше 0)', 'error'); return; }
|
||||
|
||||
_gamAwarding = true;
|
||||
try {
|
||||
const r = await LS.adminGamAward({ userId, xp, coins, reason });
|
||||
LS.toast(`Начислено! XP: ${r.xp}, Уровень: ${r.level}, Монеты: ${r.coins}`, 'success');
|
||||
const userName = document.getElementById('gam-award-uid').selectedOptions?.[0]?.textContent || 'пользователю';
|
||||
LS.toast(`Начислено ${userName}: XP +${xp} Монеты +${coins} → Уровень ${r.level}`, 'success');
|
||||
// Reset form
|
||||
document.getElementById('gam-award-uid').value = '';
|
||||
document.getElementById('gam-award-user').value = '';
|
||||
gamSetXP(0);
|
||||
gamSetCoins(0);
|
||||
document.getElementById('gam-award-reason').value = '';
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
finally { _gamAwarding = false; }
|
||||
}
|
||||
|
||||
/* ── Сбросить прогресс ───────────────────────────────────────────────── */
|
||||
async function gamAdminReset() {
|
||||
const userId = parseInt(document.getElementById('gam-reset-uid').value);
|
||||
const userName = document.getElementById('gam-reset-user').value;
|
||||
const sel = document.getElementById('gam-reset-uid');
|
||||
const userId = parseInt(sel?.value, 10);
|
||||
const userName = sel?.selectedOptions?.[0]?.textContent || '';
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (!await LS.confirm(`ВСЕ XP, монеты и достижения «${userName}» будут удалены безвозвратно.`, { title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true })) return;
|
||||
if (!await LS.confirm(
|
||||
`ВСЕ XP, монеты и достижения пользователя «${userName}» будут удалены безвозвратно.`,
|
||||
{ title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true }
|
||||
)) return;
|
||||
try {
|
||||
await LS.adminGamReset({ userId });
|
||||
LS.toast('Прогресс сброшен', 'success');
|
||||
document.getElementById('gam-reset-uid').value = '';
|
||||
document.getElementById('gam-reset-user').value = '';
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
LS.toast('Прогресс сброшен: ' + userName, 'success');
|
||||
sel.value = '';
|
||||
inited = false; await load(); inited = true;
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
window.gamSearchUser = gamSearchUser;
|
||||
window.gamPickUser = gamPickUser;
|
||||
window.gamAdminAward = gamAdminAward;
|
||||
window.gamAdminReset = gamAdminReset;
|
||||
window.gamAdminAward = gamAdminAward;
|
||||
window.gamAdminReset = gamAdminReset;
|
||||
window.gamSetXP = gamSetXP;
|
||||
window.gamSetCoins = gamSetCoins;
|
||||
window.gamSetReason = gamSetReason;
|
||||
window.gamFilterUsers = filterUserSelect;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.gam = {
|
||||
|
||||
@@ -71,15 +71,15 @@
|
||||
const diffCls = `diff-${q.difficulty}`;
|
||||
const optsHtml = (q.options || []).map(o =>
|
||||
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
|
||||
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${esc(o.text)}
|
||||
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${q.allow_html ? o.text : esc(o.text)}
|
||||
</div>`).join('');
|
||||
const explHtml = q.explanation
|
||||
? `<div class="q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||||
? `<div class="q-expl"><strong>Пояснение:</strong> ${q.allow_html ? q.explanation : esc(q.explanation)}</div>` : '';
|
||||
return `<div class="q-card" id="qcard-${q.id}">
|
||||
<div class="q-card-head">
|
||||
<span class="q-card-num">#${q.id}</span>
|
||||
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
|
||||
<div class="q-card-text">${esc(q.text)}</div>
|
||||
<div class="q-card-text">${q.allow_html ? q.text : esc(q.text)}</div>
|
||||
<div class="q-card-meta">
|
||||
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
|
||||
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
|
||||
|
||||
@@ -911,15 +911,53 @@
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Экспорт ──────────────────────────────────────────────────────────── */
|
||||
global.BIO = {
|
||||
/* ── Валентность: подробная проверка с подсказками (Фаза 2.4) ──────────
|
||||
* Возвращает массив проблем-«перевалентностей» с готовым человекочитаемым
|
||||
* текстом: [{ id, symbol, name, used, max, over, kind:'error', msg }].
|
||||
* Работает с обоими форматами связей (from/to/order и f/t/o) через bF/bT/bO.
|
||||
*/
|
||||
function _bondWord(n) {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return 'связь';
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'связи';
|
||||
return 'связей';
|
||||
}
|
||||
function valency(atoms, bonds) {
|
||||
if (!atoms || !atoms.length) return [];
|
||||
const sum = {};
|
||||
for (const b of (bonds || [])) {
|
||||
const f = bF(b), t = bT(b), o = bO(b);
|
||||
sum[f] = (sum[f] || 0) + o;
|
||||
sum[t] = (sum[t] || 0) + o;
|
||||
}
|
||||
const out = [];
|
||||
for (const a of atoms) {
|
||||
const e = el(a.s);
|
||||
const used = sum[a.id] || 0;
|
||||
const max = e.maxV != null ? e.maxV : 4;
|
||||
if (used > max) {
|
||||
const over = used - max;
|
||||
out.push({
|
||||
id: a.id, symbol: a.s, name: e.name, used, max, over, kind: 'error',
|
||||
msg: e.name + ' (' + a.s + '): занято ' + used + ' ' + _bondWord(used) +
|
||||
', максимум ' + max + ' — убери ' + over,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── Экспорт (браузер: window.BIO; Node: module.exports) ──────────────── */
|
||||
var _api = {
|
||||
ELEMENTS, el,
|
||||
bF, bT, bO,
|
||||
counts, hillFormula, molarMass, parseFormula, dbe,
|
||||
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
|
||||
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze, valency,
|
||||
balance, parseSmiles, toJSON, download,
|
||||
render2D, vsepr, render3D, chargeColor,
|
||||
safe, RING_TEMPLATES,
|
||||
_hexRgb, _lighten, _darken,
|
||||
};
|
||||
})(window);
|
||||
global.BIO = _api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = _api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
'use strict';
|
||||
/* biochem-nav.js — навигация теперь статична в HTML каждой страницы.
|
||||
* Файл сохранён для обратной совместимости (подключён на всех страницах).
|
||||
* Активная вкладка проставляется сервером через class="bsn-active" в HTML. */
|
||||
@@ -0,0 +1,303 @@
|
||||
/* chem7_anim.js — анимационный движок флагманов учебника «Химия 7».
|
||||
* Неймспейс window.Chem7Anim. Используется виджетами chem7_chN_widgets.js.
|
||||
*
|
||||
* Принципы (см. plans/textbooks-7/PLAN_CHEMISTRY_7_VISUAL.md):
|
||||
* - один RAF-цикл на флагман, пауза вне вьюпорта (IntersectionObserver), stop() при уходе;
|
||||
* - prefers-reduced-motion → статичный кадр вместо цикла;
|
||||
* - тёмная тема через CSS-переменные; без эмодзи;
|
||||
* - молекулы — SVG (надёжно в jsdom); частицы/пламя/пузырьки — canvas, но в headless
|
||||
* (jsdom-тесты) getContext НЕ вызывается, строится только DOM-каркас.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
var D = W.document;
|
||||
var HEADLESS = (typeof navigator !== 'undefined' && /jsdom|HeadlessChrome/i.test(navigator.userAgent || ''));
|
||||
function reduced() {
|
||||
try { return !!(W.matchMedia && W.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { return false; }
|
||||
}
|
||||
function ease(t) { return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
|
||||
function now() { try { return W.performance && W.performance.now ? W.performance.now() : Date.now(); } catch (e) { return Date.now(); } }
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
/* наблюдатель видимости (пауза вне экрана); в jsdom IO нет → всегда видим */
|
||||
function observeVisible(host, cb) {
|
||||
if (typeof IntersectionObserver === 'undefined') { cb(true); return { disconnect: function () {} }; }
|
||||
var io = new IntersectionObserver(function (es) { cb(es[0] && es[0].isIntersecting); }, { threshold: 0.01 });
|
||||
io.observe(host); return io;
|
||||
}
|
||||
|
||||
/* RAF-цикл с паузой вне экрана. step(dt, tSec). Возвращает {stop()}. */
|
||||
function loop(host, step, opts) {
|
||||
opts = opts || {};
|
||||
var raf = 0, running = true, visible = true, last = now(), t0 = last;
|
||||
if (reduced() || HEADLESS) { try { step(0, 0); } catch (e) {} return { stop: function () {} }; }
|
||||
var io = observeVisible(host, function (v) { visible = v; });
|
||||
function frame() {
|
||||
if (!running) return;
|
||||
var t = now(), dt = Math.min(0.05, (t - last) / 1000); last = t;
|
||||
if (visible) { try { step(dt, (t - t0) / 1000); } catch (e) { running = false; return; } }
|
||||
raf = W.requestAnimationFrame(frame);
|
||||
}
|
||||
raf = W.requestAnimationFrame(frame);
|
||||
return { stop: function () { running = false; try { W.cancelAnimationFrame(raf); } catch (e) {} try { io.disconnect(); } catch (e) {} } };
|
||||
}
|
||||
|
||||
/* создать canvas в host; ctx=null в headless (getContext не зовём) */
|
||||
function sceneCanvas(host, w, h) {
|
||||
host.innerHTML = '';
|
||||
var cv = D.createElement('canvas');
|
||||
var dpr = HEADLESS ? 1 : (W.devicePixelRatio || 1);
|
||||
cv.width = w * dpr; cv.height = h * dpr;
|
||||
cv.style.width = '100%'; cv.style.maxWidth = w + 'px'; cv.style.height = 'auto';
|
||||
cv.style.borderRadius = '12px'; cv.style.display = 'block';
|
||||
host.appendChild(cv);
|
||||
var ctx = null;
|
||||
if (!HEADLESS) { try { ctx = cv.getContext('2d'); if (ctx) ctx.scale(dpr, dpr); } catch (e) { ctx = null; } }
|
||||
return { cv: cv, ctx: ctx, w: w, h: h };
|
||||
}
|
||||
|
||||
function cssVar(name, fallback) {
|
||||
try { var v = getComputedStyle(D.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
/* ---- 3D-молекула (SVG, depth-sorted, авто-вращение + drag) ---- */
|
||||
var ELEM = {
|
||||
H: { c: '#e2e8f0', r: 0.32 }, O: { c: '#ef4444', r: 0.46 }, N: { c: '#3b82f6', r: 0.45 },
|
||||
C: { c: '#334155', r: 0.46 }, S: { c: '#eab308', r: 0.52 }, Cl: { c: '#22c55e', r: 0.5 }
|
||||
};
|
||||
function molecule3d(host, spec) {
|
||||
var W0 = spec.size || 240, H0 = spec.size || 200, K = (spec.scale || 52);
|
||||
var ns = 'http://www.w3.org/2000/svg';
|
||||
host.innerHTML = '';
|
||||
var svg = D.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 ' + W0 + ' ' + H0);
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.style.maxWidth = W0 + 'px'; svg.style.height = 'auto'; svg.style.touchAction = 'none'; svg.style.cursor = 'grab';
|
||||
// defs: радиальные градиенты по элементам
|
||||
var defs = D.createElementNS(ns, 'defs'); var used = {};
|
||||
spec.atoms.forEach(function (a) { used[a.el] = 1; });
|
||||
Object.keys(used).forEach(function (el) {
|
||||
var col = (ELEM[el] || { c: '#94a3b8' }).c;
|
||||
var g = D.createElementNS(ns, 'radialGradient');
|
||||
g.setAttribute('id', 'g_' + el); g.setAttribute('cx', '35%'); g.setAttribute('cy', '32%'); g.setAttribute('r', '70%');
|
||||
g.innerHTML = '<stop offset="0%" stop-color="#fff" stop-opacity="0.92"/><stop offset="38%" stop-color="' + col + '"/><stop offset="100%" stop-color="' + col + '" stop-opacity="0.78"/>';
|
||||
defs.appendChild(g);
|
||||
});
|
||||
svg.appendChild(defs);
|
||||
var grp = D.createElementNS(ns, 'g'); svg.appendChild(grp);
|
||||
host.appendChild(svg);
|
||||
|
||||
var ay = spec.startY != null ? spec.startY : 0.5, ax = -0.35;
|
||||
function render(angY, angX) {
|
||||
var cy0 = Math.cos(angY), sy0 = Math.sin(angY), cx0 = Math.cos(angX), sx0 = Math.sin(angX);
|
||||
var pts = spec.atoms.map(function (a, i) {
|
||||
var x = a.x * cy0 + a.z * sy0, z1 = -a.x * sy0 + a.z * cy0;
|
||||
var y = a.y * cx0 - z1 * sx0, z = a.y * sx0 + z1 * cx0;
|
||||
var depth = (z + 2) / 4; // 0..1
|
||||
return { i: i, el: a.el, sx: W0 / 2 + x * K, sy: H0 / 2 - y * K, z: z, depth: depth };
|
||||
});
|
||||
var order = pts.slice().sort(function (p, q) { return p.z - q.z; });
|
||||
var html = '';
|
||||
// связи (рисуем между центрами, под атомами по глубине ближнего конца)
|
||||
(spec.bonds || []).forEach(function (b) {
|
||||
var p = pts[b[0]], q = pts[b[1]];
|
||||
html += '<line x1="' + p.sx.toFixed(1) + '" y1="' + p.sy.toFixed(1) + '" x2="' + q.sx.toFixed(1) + '" y2="' + q.sy.toFixed(1) + '" stroke="#94a3b8" stroke-width="' + (5 + 3 * (p.depth + q.depth) / 2).toFixed(1) + '" stroke-linecap="round" opacity="0.55"/>';
|
||||
});
|
||||
order.forEach(function (p) {
|
||||
var er = (ELEM[p.el] || { r: 0.42 }).r, r = er * K * (0.72 + 0.5 * p.depth);
|
||||
html += '<circle cx="' + p.sx.toFixed(1) + '" cy="' + p.sy.toFixed(1) + '" r="' + r.toFixed(1) + '" fill="url(#g_' + p.el + ')" opacity="' + (0.6 + 0.4 * p.depth).toFixed(2) + '"/>';
|
||||
if (r > 9) html += '<text x="' + p.sx.toFixed(1) + '" y="' + (p.sy + r * 0.32).toFixed(1) + '" text-anchor="middle" font-size="' + (r * 0.85).toFixed(1) + '" font-weight="700" fill="#fff" opacity="' + (0.5 + 0.5 * p.depth).toFixed(2) + '">' + p.el + '</text>';
|
||||
});
|
||||
grp.innerHTML = html;
|
||||
}
|
||||
render(ay, ax);
|
||||
// drag-вращение (без setPointerCapture — правило проекта)
|
||||
var dragging = false, lx = 0, ly = 0;
|
||||
function down(e) { dragging = true; svg.style.cursor = 'grabbing'; var p = pt(e); lx = p.x; ly = p.y; }
|
||||
function move(e) { if (!dragging) return; var p = pt(e); ay += (p.x - lx) * 0.01; ax += (p.y - ly) * 0.01; lx = p.x; ly = p.y; render(ay, ax); }
|
||||
function up() { dragging = false; svg.style.cursor = 'grab'; }
|
||||
function pt(e) { var t = e.touches && e.touches[0] ? e.touches[0] : e; return { x: t.clientX || 0, y: t.clientY || 0 }; }
|
||||
svg.addEventListener('pointerdown', down);
|
||||
W.addEventListener('pointermove', move); W.addEventListener('pointerup', up);
|
||||
var h = loop(host, function (dt) { if (!dragging) { ay += dt * 0.6; render(ay, ax); } });
|
||||
return { stop: function () { h.stop(); try { W.removeEventListener('pointermove', move); W.removeEventListener('pointerup', up); } catch (e) {} } };
|
||||
}
|
||||
|
||||
/* ---- сцена разделения смесей (canvas) ---- */
|
||||
function separation(host, kind) {
|
||||
var w = 300, h = 200, sc = sceneCanvas(host, w, h), ctx = sc.ctx;
|
||||
if (!ctx) return { stop: function () {} }; // headless: только каркас
|
||||
var P = [];
|
||||
function reset() {
|
||||
P = [];
|
||||
if (kind === 'magnet') {
|
||||
for (var i = 0; i < 60; i++) P.push({ x: rand(40, 260), y: rand(40, 150), iron: i % 2 === 0, vx: 0, vy: 0 });
|
||||
} else if (kind === 'filter') {
|
||||
for (var j = 0; j < 50; j++) P.push({ x: rand(120, 180), y: rand(10, 60), sand: j % 2 === 0, vy: rand(20, 50), settled: false });
|
||||
} else if (kind === 'evaporate') {
|
||||
for (var k = 0; k < 28; k++) P.push({ x: rand(90, 210), y: rand(120, 150), vy: -rand(12, 26), life: rand(0, 1) });
|
||||
} else if (kind === 'settle') {
|
||||
for (var m = 0; m < 60; m++) P.push({ x: rand(70, 230), y: rand(60, 150), oil: m % 2 === 0, vy: 0 });
|
||||
} else { // distill
|
||||
for (var n = 0; n < 24; n++) P.push({ x: rand(60, 90), y: rand(120, 150), vy: -rand(14, 26), phase: 0 });
|
||||
}
|
||||
}
|
||||
reset();
|
||||
var pri = cssVar('--pri', '#059669');
|
||||
function draw(dt, t) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (kind === 'filter') {
|
||||
ctx.strokeStyle = '#94a3b8'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(110, 30); ctx.lineTo(150, 110); ctx.lineTo(190, 30); ctx.stroke(); // воронка
|
||||
ctx.fillStyle = '#bfdbfe'; ctx.fillRect(120, 165, 60, 28); // стакан с водой
|
||||
P.forEach(function (p) {
|
||||
if (p.sand) { if (p.y < 100) p.y += p.vy * dt; ctx.fillStyle = '#a16207'; }
|
||||
else { p.y += p.vy * dt; if (p.y > 200) { p.y = 165; } ctx.fillStyle = '#3b82f6'; }
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.sand ? 2.5 : 2, 0, 7); ctx.fill();
|
||||
});
|
||||
} else if (kind === 'evaporate') {
|
||||
ctx.fillStyle = '#fde68a'; ctx.beginPath(); ctx.ellipse(150, 150, 70, 16, 0, 0, 7); ctx.fill(); // чашка
|
||||
// кристаллы соли растут
|
||||
var grow = Math.min(1, t / 6);
|
||||
for (var i = 0; i < 10; i++) { var s = 2 + grow * 5; ctx.fillStyle = '#e5e7eb'; ctx.fillRect(110 + i * 8, 150 - s, s, s); }
|
||||
P.forEach(function (p) { p.y += p.vy * dt; p.life += dt * 0.4; if (p.y < 20 || p.life > 1) { p.y = rand(140, 150); p.x = rand(100, 200); p.life = 0; }
|
||||
ctx.fillStyle = 'rgba(203,213,225,' + (0.6 * (1 - p.life)).toFixed(2) + ')'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); });
|
||||
} else if (kind === 'magnet') {
|
||||
ctx.fillStyle = '#475569'; ctx.fillRect(135, 6, 30, 18); ctx.fillStyle = '#dc2626'; ctx.fillRect(135, 6, 15, 18); // магнит
|
||||
P.forEach(function (p) {
|
||||
if (p.iron) { var dx = 150 - p.x, dy = 22 - p.y, d = Math.hypot(dx, dy) || 1; p.x += dx / d * 70 * dt; p.y += dy / d * 70 * dt; ctx.fillStyle = '#475569'; }
|
||||
else { ctx.fillStyle = '#eab308'; }
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, 2.6, 0, 7); ctx.fill();
|
||||
});
|
||||
} else if (kind === 'settle') {
|
||||
P.forEach(function (p) { var target = p.oil ? 80 : 150; p.y += (target - p.y) * Math.min(1, dt * 1.5); ctx.fillStyle = p.oil ? '#fbbf24' : '#3b82f6'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); });
|
||||
} else {
|
||||
ctx.fillStyle = '#94a3b8'; ctx.fillRect(40, 150, 60, 10); ctx.fillRect(210, 150, 60, 30); // колба и приёмник
|
||||
P.forEach(function (p) { if (p.phase === 0) { p.y += p.vy * dt; if (p.y < 40) { p.phase = 1; } } else { p.x += 60 * dt; p.y += 30 * dt; if (p.x > 240) { p.x = rand(60, 90); p.y = rand(120, 150); p.phase = 0; } }
|
||||
ctx.fillStyle = p.phase === 0 ? 'rgba(148,163,184,.7)' : '#3b82f6'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); });
|
||||
}
|
||||
}
|
||||
return loop(host, draw);
|
||||
}
|
||||
|
||||
/* ---- плавная смена цвета (индикаторы, осадки) ---- */
|
||||
function colorMorph(el, toColor, ms) {
|
||||
if (!el) return; ms = ms || 600;
|
||||
el.style.transition = 'background-color ' + ms + 'ms ease, color ' + ms + 'ms ease';
|
||||
el.style.backgroundColor = toColor;
|
||||
}
|
||||
|
||||
/* ---- лёгкое конфетти (SVG, без CDN) ---- */
|
||||
function confettiSmall(host) {
|
||||
if (HEADLESS || reduced() || !host) return;
|
||||
var cols = ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa'];
|
||||
var box = D.createElement('div'); box.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden';
|
||||
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
||||
host.appendChild(box);
|
||||
for (var i = 0; i < 18; i++) {
|
||||
var s = D.createElement('div');
|
||||
s.style.cssText = 'position:absolute;top:-8px;left:' + rand(5, 95) + '%;width:7px;height:7px;border-radius:2px;background:' + cols[i % cols.length] + ';opacity:.9;transition:transform 1.1s cubic-bezier(.3,.7,.3,1),opacity 1.1s';
|
||||
box.appendChild(s);
|
||||
(function (el) { W.requestAnimationFrame(function () { el.style.transform = 'translateY(' + rand(120, 220) + 'px) rotate(' + rand(180, 540) + 'deg)'; el.style.opacity = '0'; }); })(s);
|
||||
}
|
||||
setTimeout(function () { try { host.removeChild(box); } catch (e) {} }, 1300);
|
||||
}
|
||||
|
||||
/* ---- CSS-анимации (jsdom-safe, без canvas): пузырьки, осадок, пламя, смена цвета ---- */
|
||||
function injectKeyframes() {
|
||||
if (D.getElementById('chem7-kf')) return;
|
||||
var st = D.createElement('style'); st.id = 'chem7-kf';
|
||||
st.textContent =
|
||||
'@keyframes c7-rise{0%{transform:translateY(0) scale(.6);opacity:0}15%{opacity:.9}100%{transform:translateY(-92px) scale(1);opacity:0}}'
|
||||
+ '@keyframes c7-fall{0%{transform:translateY(-26px);opacity:0}18%{opacity:1}100%{transform:translateY(58px);opacity:.85}}'
|
||||
+ '@keyframes c7-flick{0%,100%{transform:scaleY(1);opacity:.92}50%{transform:scaleY(1.18) translateY(-3px);opacity:1}}';
|
||||
(D.head || D.documentElement).appendChild(st);
|
||||
}
|
||||
function fieldHost(host, h) {
|
||||
host.innerHTML = ''; host.style.position = 'relative'; host.style.height = h + 'px';
|
||||
host.style.overflow = 'hidden'; host.style.borderRadius = '12px';
|
||||
return host;
|
||||
}
|
||||
// поток пузырьков газа снизу вверх
|
||||
function bubbleField(host, opts) {
|
||||
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
|
||||
host.style.background = opts.bg || 'linear-gradient(180deg,var(--pri-soft),transparent)';
|
||||
var n = opts.count || 14, col = opts.color || 'rgba(255,255,255,.85)';
|
||||
for (var i = 0; i < n; i++) {
|
||||
var d = D.createElement('div'), sz = rand(5, 11);
|
||||
d.style.cssText = 'position:absolute;bottom:6px;left:' + rand(8, 92) + '%;width:' + sz + 'px;height:' + sz + 'px;border-radius:50%;background:' + col + ';border:1px solid rgba(0,0,0,.12);animation:c7-rise ' + rand(1.3, 2.4).toFixed(2) + 's linear ' + rand(0, 1.6).toFixed(2) + 's infinite';
|
||||
host.appendChild(d);
|
||||
}
|
||||
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
|
||||
}
|
||||
// осадок: частицы падают и оседают слоем
|
||||
function precipField(host, opts) {
|
||||
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
|
||||
host.style.background = opts.bg || 'linear-gradient(180deg,transparent,var(--pri-soft))';
|
||||
var n = opts.count || 16, col = opts.color || '#38bdf8';
|
||||
var sed = D.createElement('div'); sed.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:14px;background:' + col + ';opacity:.55;border-radius:0 0 12px 12px'; host.appendChild(sed);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var d = D.createElement('div'), sz = rand(5, 9);
|
||||
d.style.cssText = 'position:absolute;top:8px;left:' + rand(8, 92) + '%;width:' + sz + 'px;height:' + sz + 'px;border-radius:50%;background:' + col + ';animation:c7-fall ' + rand(1.1, 2.0).toFixed(2) + 's ease-in ' + rand(0, 1.4).toFixed(2) + 's infinite';
|
||||
host.appendChild(d);
|
||||
}
|
||||
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
|
||||
}
|
||||
// пламя (мерцающая капля-градиент)
|
||||
function flameBox(host, opts) {
|
||||
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
|
||||
var col = opts.color || '#f97316';
|
||||
var f = D.createElement('div');
|
||||
f.style.cssText = 'position:absolute;left:50%;bottom:10px;transform:translateX(-50%);transform-origin:bottom center;width:46px;height:78px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:radial-gradient(circle at 50% 75%,#fde047,' + col + ' 60%,transparent 78%);animation:c7-flick .5s ease-in-out infinite';
|
||||
host.appendChild(f);
|
||||
if (opts.sparks) for (var i = 0; i < 8; i++) { var s = D.createElement('div'); s.style.cssText = 'position:absolute;bottom:14px;left:' + rand(38, 62) + '%;width:3px;height:3px;border-radius:50%;background:#fb923c;animation:c7-rise ' + rand(.8, 1.4).toFixed(2) + 's linear ' + rand(0, 1).toFixed(2) + 's infinite'; host.appendChild(s); }
|
||||
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
|
||||
}
|
||||
// блок вещества с плавной сменой цвета (зелёный→чёрный и т.п.)
|
||||
function colorBlock(host, fromC, toC, label, ms) {
|
||||
fieldHost(host, 90); ms = ms || 1800;
|
||||
var b = D.createElement('div');
|
||||
b.style.cssText = 'position:absolute;inset:14px;border-radius:10px;background:' + fromC + ';transition:background ' + ms + 'ms ease;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.4)';
|
||||
b.textContent = label || '';
|
||||
host.appendChild(b);
|
||||
W.requestAnimationFrame(function () { W.requestAnimationFrame(function () { b.style.background = toC; }); });
|
||||
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} }, el: b };
|
||||
}
|
||||
|
||||
/* ---- валентные «крючки»: атомы A и B с чёрточками-связями, соединяющимися (§9) ---- */
|
||||
function valenceLink(host, spec) {
|
||||
var ns = 'http://www.w3.org/2000/svg';
|
||||
var na = spec.a.n, nb = spec.b.n, va = spec.a.val, vb = spec.b.val, lcm = va * na;
|
||||
var W0 = 300, r = 17, lx = 50, rx = W0 - 50;
|
||||
var H0 = Math.max(na, nb, 1) * 50 + 20;
|
||||
function ycol(n, k) { var gap = 50, top = (H0 - (n - 1) * gap) / 2; return top + k * gap; }
|
||||
function spread(idx, val) { return (idx - (val - 1) / 2) * 9; }
|
||||
var colA = spec.a.color || '#6366f1', colB = spec.b.color || '#ef4444';
|
||||
var bonds = '';
|
||||
for (var t = 0; t < lcm; t++) {
|
||||
var la = Math.floor(t / va), rb = Math.floor(t / vb);
|
||||
var sy = ycol(na, la) + spread(t % va, va), ey = ycol(nb, rb) + spread(t % vb, vb);
|
||||
var sx = lx + r, ex = rx - r, len = Math.hypot(ex - sx, ey - sy);
|
||||
bonds += '<line x1="' + sx + '" y1="' + sy.toFixed(1) + '" x2="' + ex + '" y2="' + ey.toFixed(1) + '" stroke="#94a3b8" stroke-width="3" stroke-linecap="round" stroke-dasharray="' + len.toFixed(1) + '" stroke-dashoffset="' + (HEADLESS ? 0 : len.toFixed(1)) + '" style="transition:stroke-dashoffset .5s ease ' + (t * 0.08).toFixed(2) + 's"/>';
|
||||
}
|
||||
function atom(x, y, el, col) {
|
||||
return '<circle cx="' + x + '" cy="' + y.toFixed(1) + '" r="' + r + '" fill="' + col + '" stroke="rgba(0,0,0,.2)"/>'
|
||||
+ '<text x="' + x + '" y="' + (y + 4).toFixed(1) + '" text-anchor="middle" font-size="13" font-weight="700" fill="#fff">' + el + '</text>';
|
||||
}
|
||||
var atoms = '';
|
||||
for (var i = 0; i < na; i++) atoms += atom(lx, ycol(na, i), spec.a.el, colA);
|
||||
for (var j = 0; j < nb; j++) atoms += atom(rx, ycol(nb, j), spec.b.el, colB);
|
||||
host.innerHTML = '<svg viewBox="0 0 ' + W0 + ' ' + H0 + '" width="100%" style="max-width:' + W0 + 'px;height:auto">' + bonds + atoms + '</svg>';
|
||||
if (!HEADLESS && !reduced()) {
|
||||
var lines = host.querySelectorAll('line');
|
||||
W.requestAnimationFrame(function () { W.requestAnimationFrame(function () { lines.forEach(function (l) { l.setAttribute('stroke-dashoffset', '0'); }); }); });
|
||||
}
|
||||
return { stop: function () { try { host.innerHTML = ''; } catch (e) {} } };
|
||||
}
|
||||
|
||||
W.Chem7Anim = {
|
||||
HEADLESS: HEADLESS, reduced: reduced, ease: ease, loop: loop, sceneCanvas: sceneCanvas,
|
||||
molecule3d: molecule3d, separation: separation, colorMorph: colorMorph, confettiSmall: confettiSmall, observeVisible: observeVisible,
|
||||
bubbleField: bubbleField, precipField: precipField, flameBox: flameBox, colorBlock: colorBlock, valenceLink: valenceLink
|
||||
};
|
||||
})(window);
|
||||
@@ -67,17 +67,19 @@
|
||||
|
||||
/* §2 / ПР1 — разделитель смесей: выбери метод для смеси */
|
||||
var MIX = [
|
||||
{ mix: 'Песок и вода', method: 'Фильтрование', why: 'Песок не растворяется — задерживается фильтром, вода проходит.' },
|
||||
{ mix: 'Соль и вода', method: 'Выпаривание', why: 'Вода испаряется, соль остаётся на дне.' },
|
||||
{ mix: 'Железные опилки и сера', method: 'Магнит', why: 'Железо притягивается магнитом, сера — нет.' },
|
||||
{ mix: 'Вода и растительное масло', method: 'Отстаивание (делительная воронка)', why: 'Масло легче воды и не смешивается — слои разделяют.' },
|
||||
{ mix: 'Спирт и вода', method: 'Перегонка (дистилляция)', why: 'У спирта и воды разные температуры кипения.' }
|
||||
{ mix: 'Песок и вода', method: 'Фильтрование', kind: 'filter', why: 'Песок не растворяется — задерживается фильтром, вода проходит.' },
|
||||
{ mix: 'Соль и вода', method: 'Выпаривание', kind: 'evaporate', why: 'Вода испаряется, соль остаётся на дне.' },
|
||||
{ mix: 'Железные опилки и сера', method: 'Магнит', kind: 'magnet', why: 'Железо притягивается магнитом, сера — нет.' },
|
||||
{ mix: 'Вода и растительное масло', method: 'Отстаивание (делительная воронка)', kind: 'settle', why: 'Масло легче воды и не смешивается — слои разделяют.' },
|
||||
{ mix: 'Спирт и вода', method: 'Перегонка (дистилляция)', kind: 'distill', why: 'У спирта и воды разные температуры кипения.' }
|
||||
];
|
||||
var METHODS = ['Фильтрование', 'Выпаривание', 'Магнит', 'Отстаивание (делительная воронка)', 'Перегонка (дистилляция)'];
|
||||
function mount_sep(mountId) {
|
||||
var m = $(mountId); if (!m || m._built) return; m._built = 1;
|
||||
var idx = 0;
|
||||
var idx = 0, anim = null;
|
||||
function stopAnim() { if (anim) { anim.stop(); anim = null; } }
|
||||
function render() {
|
||||
stopAnim();
|
||||
var cur = MIX[idx];
|
||||
m.innerHTML = '<div class="fld"><label>Смесь</label><select id="' + mountId + '-pick">'
|
||||
+ MIX.map(function (x, i) { return '<option value="' + i + '"' + (i === idx ? ' selected' : '') + '>' + esc(x.mix) + '</option>'; }).join('') + '</select></div>'
|
||||
@@ -85,8 +87,9 @@
|
||||
+ '<div style="display:flex;flex-wrap:wrap;gap:6px">' + METHODS.map(function (mt) {
|
||||
return '<button class="c7-m btn" data-m="' + esc(mt) + '">' + esc(mt) + '</button>';
|
||||
}).join('') + '</div>'
|
||||
+ '<div class="out" id="' + mountId + '-out" style="margin-top:8px">Выбери способ разделения.</div>';
|
||||
$(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; m._built = 0; render(); });
|
||||
+ '<div class="out" id="' + mountId + '-out" style="margin-top:8px">Выбери способ разделения — при верном ответе увидишь анимацию.</div>'
|
||||
+ '<div id="' + mountId + '-anim" style="margin-top:8px;display:flex;justify-content:center"></div>';
|
||||
$(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; render(); });
|
||||
var out = $(mountId + '-out');
|
||||
m.querySelectorAll('.c7-m').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
@@ -95,6 +98,10 @@
|
||||
out.innerHTML = ok
|
||||
? '<b>Верно!</b> ' + esc(cur.method) + '. ' + esc(cur.why)
|
||||
: '<b>Не подходит.</b> Подумай, чем различаются вещества в смеси (растворимость, магнитные свойства, температура кипения, плотность).';
|
||||
stopAnim();
|
||||
var host = $(mountId + '-anim');
|
||||
if (ok && W.Chem7Anim && host) anim = W.Chem7Anim.separation(host, cur.kind);
|
||||
else if (host) host.innerHTML = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -201,19 +208,39 @@
|
||||
+'<div style="font-size:.82rem;color:var(--muted);margin-top:4px">'+esc(note)+'</div></div>';
|
||||
}
|
||||
|
||||
/* §5 — галерея простых веществ */
|
||||
function mount_p5() {
|
||||
var m = $('p5-gal'); if (!m || m._built) return; m._built = 1;
|
||||
m.innerHTML = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px">'
|
||||
+ molCard('Водород','H2',[['H',2]],'2 атома H — двухатомная молекула')
|
||||
+ molCard('Кислород','O2',[['O',2]],'2 атома O')
|
||||
+ molCard('Озон','O3',[['O',3]],'3 атома O — тоже простое вещество')
|
||||
+ molCard('Азот','N2',[['N',2]],'2 атома N')
|
||||
+ '</div><div style="font-size:.84rem;color:var(--muted);margin-top:8px">Во всех молекулах — атомы <b>одного</b> элемента → это <b>простые вещества</b>. Кислород $\\text{O}_2$ и озон $\\text{O}_3$ образованы одним элементом, но это разные простые вещества.</div>';
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(m); } catch(e){}
|
||||
/* 3D-модели молекул для §5/§6 (через Chem7Anim.molecule3d) */
|
||||
var MOL = {
|
||||
H2: { atoms:[{el:'H',x:-0.7,y:0,z:0},{el:'H',x:0.7,y:0,z:0}], bonds:[[0,1]] },
|
||||
O2: { atoms:[{el:'O',x:-0.75,y:0,z:0},{el:'O',x:0.75,y:0,z:0}], bonds:[[0,1]] },
|
||||
O3: { atoms:[{el:'O',x:0,y:0.45,z:0},{el:'O',x:-1.05,y:-0.4,z:0},{el:'O',x:1.05,y:-0.4,z:0}], bonds:[[0,1],[0,2]] },
|
||||
N2: { atoms:[{el:'N',x:-0.7,y:0,z:0},{el:'N',x:0.7,y:0,z:0}], bonds:[[0,1]] },
|
||||
H2O: { atoms:[{el:'O',x:0,y:0,z:0},{el:'H',x:-0.78,y:0.6,z:0},{el:'H',x:0.78,y:0.6,z:0}], bonds:[[0,1],[0,2]] },
|
||||
CO2: { atoms:[{el:'C',x:0,y:0,z:0},{el:'O',x:-1.15,y:0,z:0},{el:'O',x:1.15,y:0,z:0}], bonds:[[0,1],[0,2]] },
|
||||
CH4: { atoms:[{el:'C',x:0,y:0,z:0},{el:'H',x:0.63,y:0.63,z:0.63},{el:'H',x:-0.63,y:-0.63,z:0.63},{el:'H',x:-0.63,y:0.63,z:-0.63},{el:'H',x:0.63,y:-0.63,z:-0.63}], bonds:[[0,1],[0,2],[0,3],[0,4]] },
|
||||
NH3: { atoms:[{el:'N',x:0,y:0.32,z:0},{el:'H',x:0.94,y:-0.3,z:0},{el:'H',x:-0.47,y:-0.3,z:0.82},{el:'H',x:-0.47,y:-0.3,z:-0.82}], bonds:[[0,1],[0,2],[0,3]] }
|
||||
};
|
||||
function fmlName(k) { return C().formula ? C().formula(k) : k; }
|
||||
function molViewer(host, keys, caption) {
|
||||
if (!host || host._built) return; host._built = 1;
|
||||
var A = W.Chem7Anim;
|
||||
if (!A || !A.molecule3d) { host.innerHTML = '<div class="out">3D-модели недоступны.</div>'; return; }
|
||||
var cur = keys[0], handle = null;
|
||||
function render() {
|
||||
if (handle) handle.stop();
|
||||
host.innerHTML = '<div class="fld" style="flex-wrap:wrap;gap:6px">'
|
||||
+ keys.map(function (k) { return '<button class="btn mv-b' + (k === cur ? ' primary' : '') + '" data-k="' + k + '">' + fmlName(k) + '</button>'; }).join('') + '</div>'
|
||||
+ '<div id="' + host.id + '-stage" style="display:flex;justify-content:center;padding:8px 0"></div>'
|
||||
+ '<div class="out" style="margin-top:4px">' + caption + ' Перетаскивай модель мышью, чтобы повернуть.</div>';
|
||||
handle = A.molecule3d($(host.id + '-stage'), MOL[cur]);
|
||||
host.querySelectorAll('.mv-b').forEach(function (b) { b.addEventListener('click', function () { cur = b.dataset.k; render(); }); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* §6 — классификатор простое/сложное + галерея сложных веществ */
|
||||
/* §5 — 3D-модели простых веществ */
|
||||
function mount_p5() { molViewer($('p5-gal'), ['H2', 'O2', 'O3', 'N2'], 'Простое вещество — атомы одного элемента.'); }
|
||||
|
||||
/* §6 — классификатор простое/сложное + 3D-модели сложных веществ */
|
||||
function mount_p6() {
|
||||
var c = $('p6-cls');
|
||||
if (c) classifier(c, {
|
||||
@@ -223,16 +250,7 @@
|
||||
{ t:'N₂', b:0 }, { t:'NH₃', b:1 }, { t:'S', b:0 }, { t:'CH₄', b:1 }
|
||||
]
|
||||
});
|
||||
var g = $('p6-gal');
|
||||
if (g && !g._built) { g._built = 1;
|
||||
g.innerHTML = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px">'
|
||||
+ molCard('Вода','H2O',[['O',1],['H',2]],'2 элемента: H и O')
|
||||
+ molCard('Углекислый газ','CO2',[['C',1],['O',2]],'2 элемента: C и O')
|
||||
+ molCard('Метан','CH4',[['C',1],['H',4]],'2 элемента: C и H')
|
||||
+ molCard('Аммиак','NH3',[['N',1],['H',3]],'2 элемента: N и H')
|
||||
+ '</div><div style="font-size:.84rem;color:var(--muted);margin-top:8px">В каждой молекуле — атомы <b>разных</b> элементов → это <b>сложные вещества</b>.</div>';
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(g); } catch(e){}
|
||||
}
|
||||
molViewer($('p6-gal'), ['H2O', 'CO2', 'CH4', 'NH3'], 'Сложное вещество — атомы разных элементов.');
|
||||
}
|
||||
|
||||
/* ── Волна 3 ── */
|
||||
@@ -277,23 +295,30 @@
|
||||
function gcd(a, b) { return b ? gcd(b, a % b) : a; }
|
||||
var VA = [ ['Na', 1], ['K', 1], ['H', 1], ['Mg', 2], ['Ca', 2], ['Zn', 2], ['Cu', 2], ['Al', 3], ['C', 4] ];
|
||||
var VB = [ ['O', 2], ['Cl', 1], ['S', 2] ];
|
||||
var BCOL = { O:'#ef4444', Cl:'#22c55e', S:'#eab308' };
|
||||
function mount_p9() {
|
||||
var m = $('p9-bld'); if (!m || m._built) return; m._built = 1;
|
||||
var vanim = null;
|
||||
function optA(){ return VA.map(function(e,i){ return '<option value="'+i+'"'+(e[0]==='Al'?' selected':'')+'>'+e[0]+' (валентность '+'I'.repeat(e[1]).replace('IIII','IV')+')</option>'; }).join(''); }
|
||||
function optB(){ return VB.map(function(e,i){ return '<option value="'+i+'">'+e[0]+' (валентность '+'I'.repeat(e[1])+')</option>'; }).join(''); }
|
||||
m.innerHTML = '<div class="fld"><label>Элемент A</label><select id="p9-a">'+optA()+'</select>'
|
||||
+'<label>Элемент B</label><select id="p9-b">'+optB()+'</select></div><div class="out" id="p9-bout"></div>';
|
||||
+'<label>Элемент B</label><select id="p9-b">'+optB()+'</select></div>'
|
||||
+'<div id="p9-vis" style="margin:8px 0;display:flex;justify-content:center"></div>'
|
||||
+'<div class="out" id="p9-bout"></div>';
|
||||
function upd() {
|
||||
var a = VA[+$('p9-a').value], b = VB[+$('p9-b').value];
|
||||
var lcm = a[1] * b[1] / gcd(a[1], b[1]);
|
||||
var ia = lcm / a[1], ib = lcm / b[1];
|
||||
var raw = a[0] + (ia > 1 ? ia : '') + b[0] + (ib > 1 ? ib : '');
|
||||
if (vanim) { vanim.stop(); vanim = null; }
|
||||
if (W.Chem7Anim) vanim = W.Chem7Anim.valenceLink($('p9-vis'), {
|
||||
a: { el:a[0], val:a[1], n:ia, color:'#6366f1' },
|
||||
b: { el:b[0], val:b[1], n:ib, color:BCOL[b[0]] || '#ef4444' } });
|
||||
var out = $('p9-bout'); out.className = 'out ok';
|
||||
out.innerHTML = '<span class="bd">Валентности: ' + a[0] + ' = ' + 'I'.repeat(a[1]).replace('IIII','IV') + ', ' + b[0] + ' = ' + 'I'.repeat(b[1]) + '<br>'
|
||||
+ 'Наименьшее общее кратное валентностей = <b>' + lcm + '</b><br>'
|
||||
+ 'Индексы: ' + a[0] + ' → ' + ia + ', ' + b[0] + ' → ' + ib + '<br>'
|
||||
+ 'Формула: <b style="font-size:1.15rem">' + (C().formula ? C().formula(raw) : raw) + '</b><br>'
|
||||
+ 'Проверка: ' + ia + '·' + a[1] + ' = ' + ib + '·' + b[1] + ' = ' + lcm + ' единиц валентности — совпало.</span>';
|
||||
+ 'Каждая чёрточка-связь соединена — все валентности заняты.<br>'
|
||||
+ 'НОК валентностей = <b>' + lcm + '</b>; индексы: ' + a[0] + ' → ' + ia + ', ' + b[0] + ' → ' + ib + '<br>'
|
||||
+ 'Формула: <b style="font-size:1.15rem">' + (C().formula ? C().formula(raw) : raw) + '</b></span>';
|
||||
}
|
||||
$('p9-a').addEventListener('change', upd); $('p9-b').addEventListener('change', upd); upd();
|
||||
}
|
||||
@@ -307,17 +332,30 @@
|
||||
{ name: 'Горение серы', signs: ['выделение света и тепла (пламя)', 'появление резкого запаха'] },
|
||||
{ name: 'Добавление соды в уксус', signs: ['выделение газа (пузырьки)'] }
|
||||
];
|
||||
// анимация на каждый опыт (через Chem7Anim, CSS-хелперы)
|
||||
function demoAnim(idx, host) {
|
||||
var A = W.Chem7Anim; if (!A || !host) return null;
|
||||
if (idx === 0) return A.colorBlock(host, '#16a34a', '#1f2937', 'малахит → CuO + газы', 2000); // зелёный → чёрный
|
||||
if (idx === 1) return A.precipField(host, { color: '#38bdf8' }); // голубой осадок
|
||||
if (idx === 2) return A.flameBox(host, { color: '#3b82f6', sparks: true }); // синее пламя серы
|
||||
return A.bubbleField(host, { color: 'rgba(255,255,255,.85)' }); // пузырьки газа
|
||||
}
|
||||
function mount_signs(mountId) {
|
||||
var m = $(mountId); if (!m || m._built) return; m._built = 1;
|
||||
var idx = 0;
|
||||
var idx = 0, anim = null;
|
||||
function stopAnim() { if (anim) { anim.stop(); anim = null; } }
|
||||
function render() {
|
||||
stopAnim();
|
||||
m.innerHTML = '<div class="fld"><label>Опыт</label><select id="' + mountId + '-pick">'
|
||||
+ DEMOS.map(function (d, i) { return '<option value="' + i + '"' + (i === idx ? ' selected' : '') + '>' + esc(d.name) + '</option>'; }).join('') + '</select>'
|
||||
+ '<button class="btn primary" id="' + mountId + '-go">Провести опыт</button></div>'
|
||||
+ '<div id="' + mountId + '-stage" style="margin:8px 0"></div>'
|
||||
+ '<div class="out" id="' + mountId + '-out">Выбери опыт и нажми «Провести опыт».</div>';
|
||||
$(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; m._built = 0; render(); });
|
||||
$(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; render(); });
|
||||
$(mountId + '-go').addEventListener('click', function () {
|
||||
var d = DEMOS[idx], out = $(mountId + '-out'); out.className = 'out ok';
|
||||
var d = DEMOS[idx], out = $(mountId + '-out');
|
||||
stopAnim(); anim = demoAnim(idx, $(mountId + '-stage'));
|
||||
out.className = 'out ok';
|
||||
out.innerHTML = '<b>Наблюдаемые признаки реакции:</b><div style="margin-top:6px">'
|
||||
+ d.signs.map(function (s) { return '<div style="padding:5px 10px;margin:3px 0;border-radius:8px;background:var(--pri-soft);font-weight:600">✓ ' + esc(s) + '</div>'; }).join('')
|
||||
+ '</div><div style="font-size:.84rem;color:var(--muted);margin-top:6px">Эти признаки указывают, что произошла <b>химическая реакция</b> — образовались новые вещества.</div>';
|
||||
@@ -348,21 +386,49 @@
|
||||
+ '<text x="225" y="108" font-size="11" fill="var(--muted)">' + (mixed ? 'продукты' : 'реагенты') + '</text>'
|
||||
+ '</svg>';
|
||||
}
|
||||
var anim = null;
|
||||
function render() {
|
||||
if (anim) { anim.stop(); anim = null; }
|
||||
m.innerHTML = scale()
|
||||
+ '<div style="margin:6px 0;font-size:.92rem">' + (mixed
|
||||
? 'После реакции: <b>осадок Cu(OH)₂ + раствор Na₂SO₄</b>. Стрелка весов не сдвинулась — <b>масса сохранилась</b> (100 г = 100 г).'
|
||||
? 'После реакции: <b>осадок Cu(OH)₂ + раствор Na₂SO₄</b>. Стрелка весов <b>не сдвинулась</b> — масса сохранилась (100 г = 100 г).'
|
||||
: 'До реакции: <b>раствор CuSO₄ + раствор NaOH</b>, общая масса 100 г.') + '</div>'
|
||||
+ '<div id="p11-stage" style="margin:6px 0"></div>'
|
||||
+ '<button class="btn primary" id="p11-mix">' + (mixed ? 'Сбросить' : 'Смешать растворы') + '</button>';
|
||||
$('p11-mix').addEventListener('click', function () { mixed = !mixed; m._built = 0; render(); });
|
||||
if (mixed && W.Chem7Anim) anim = W.Chem7Anim.precipField($('p11-stage'), { color: '#38bdf8', h: 96 });
|
||||
$('p11-mix').addEventListener('click', function () { mixed = !mixed; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* §12 — балансировщик уравнений (переиспользуем Chem8.equationBalancer) */
|
||||
/* §12 — балансировщик + анимированный подсчёт атомов (слева/справа) */
|
||||
var ELC = { H:'#cbd5e1', O:'#ef4444', C:'#334155', N:'#3b82f6', S:'#eab308', Fe:'#b45309', P:'#f97316', Cl:'#22c55e', Mg:'#22c55e', Ca:'#a78bfa', Na:'#a78bfa', Cu:'#ea580c', Zn:'#64748b', Al:'#6366f1', K:'#a78bfa' };
|
||||
function mount_p12() {
|
||||
var pick = $('p12-pick'), mount = $('p12-mount'); if (!pick || pick._built || !C().equationBalancer) return; pick._built = 1;
|
||||
function build() { var parts = pick.value.split('|'); C().equationBalancer(mount, { skeleton: parts[0], solution: parts[1].split(',').map(Number) }); }
|
||||
if (!$('p12-tally')) mount.insertAdjacentHTML('afterend', '<div id="p12-tally" style="margin-top:10px"></div>');
|
||||
function sumSide(list, coeffs, off) {
|
||||
var tot = {};
|
||||
list.forEach(function (sp, i) { var cnt = C().elementCounts ? C().elementCounts(sp) : {}; var co = coeffs[off + i] || 1; for (var e in cnt) tot[e] = (tot[e] || 0) + cnt[e] * co; });
|
||||
return tot;
|
||||
}
|
||||
function dots(el, n) { var s = ''; for (var i = 0; i < n; i++) s += '<span class="c7-atom" style="display:inline-block;width:13px;height:13px;border-radius:50%;margin:1px;background:' + (ELC[el] || '#94a3b8') + ';transform:scale(.2);opacity:0;transition:transform .3s ease,opacity .3s ease"></span>'; return s; }
|
||||
function col(title, tot) { return '<div style="flex:1;min-width:140px"><div style="font-weight:700;font-size:.82rem;margin-bottom:4px">' + title + '</div>' + Object.keys(tot).map(function (e) { return '<div style="display:flex;align-items:center;gap:6px;margin:2px 0"><b style="width:26px">' + e + '</b>' + dots(e, tot[e]) + '<span style="color:var(--muted);font-size:.8rem">× ' + tot[e] + '</span></div>'; }).join('') + '</div>'; }
|
||||
function tally(skeleton, coeffs) {
|
||||
var t = $('p12-tally'); if (!t) return;
|
||||
var sides = skeleton.split(/->|=/);
|
||||
var L = sides[0].split('+').map(function (s) { return s.trim(); });
|
||||
var Rr = (sides[1] || '').split('+').map(function (s) { return s.trim(); });
|
||||
var left = sumSide(L, coeffs, 0), right = sumSide(Rr, coeffs, L.length);
|
||||
var ok = Object.keys(left).every(function (e) { return left[e] === right[e]; }) && Object.keys(right).every(function (e) { return left[e] === right[e]; });
|
||||
t.innerHTML = '<div style="display:flex;gap:14px;flex-wrap:wrap">' + col('Реагенты — атомы', left) + col('Продукты — атомы', right) + '</div>'
|
||||
+ '<div class="out ' + (ok ? 'ok' : 'bad') + '" style="margin-top:6px">' + (ok ? '✓ Число атомов каждого элемента слева и справа <b>совпадает</b> — уравнение сбалансировано.' : 'Атомы не уравнены.') + '</div>';
|
||||
if (W.Chem7Anim && !W.Chem7Anim.HEADLESS) {
|
||||
var a = t.querySelectorAll('.c7-atom');
|
||||
a.forEach(function (d, i) { d.style.transitionDelay = (i * 28) + 'ms'; });
|
||||
W.requestAnimationFrame(function () { W.requestAnimationFrame(function () { a.forEach(function (d) { d.style.transform = 'scale(1)'; d.style.opacity = '1'; }); }); });
|
||||
}
|
||||
}
|
||||
function build() { var parts = pick.value.split('|'); var coeffs = parts[1].split(',').map(Number); C().equationBalancer(mount, { skeleton: parts[0], solution: coeffs }); tally(parts[0], coeffs); }
|
||||
pick.addEventListener('change', build); build();
|
||||
}
|
||||
|
||||
|
||||
@@ -86,28 +86,33 @@
|
||||
|
||||
/* §15 — симулятор горения: вещество + O₂ → оксид */
|
||||
var FUELS = [
|
||||
{ el:'C', name:'углерод', eq:'C + O2 = CO2', note:'горит с образованием углекислого газа' },
|
||||
{ el:'S', name:'сера', eq:'S + O2 = SO2', note:'горит синим пламенем, резкий запах' },
|
||||
{ el:'P', name:'фосфор', eq:'4P + 5O2 = 2P2O5', note:'горит ярко, белый дым' },
|
||||
{ el:'Fe', name:'железо', eq:'3Fe + 2O2 = Fe3O4', note:'горит, разбрасывая искры' },
|
||||
{ el:'Mg', name:'магний', eq:'2Mg + O2 = 2MgO', note:'ослепительно яркое пламя' }
|
||||
{ el:'C', name:'углерод', eq:'C + O2 = CO2', flame:'#f97316', sparks:false, note:'горит с образованием углекислого газа' },
|
||||
{ el:'S', name:'сера', eq:'S + O2 = SO2', flame:'#3b82f6', sparks:false, note:'горит синим пламенем, резкий запах' },
|
||||
{ el:'P', name:'фосфор', eq:'4P + 5O2 = 2P2O5', flame:'#fde68a', sparks:false, note:'горит ярко, с белым дымом' },
|
||||
{ el:'Fe', name:'железо', eq:'3Fe + 2O2 = Fe3O4', flame:'#f59e0b', sparks:true, note:'горит, разбрасывая искры' },
|
||||
{ el:'Mg', name:'магний', eq:'2Mg + O2 = 2MgO', flame:'#e0f2fe', sparks:true, note:'горит ослепительно ярким пламенем' }
|
||||
];
|
||||
function flame(){
|
||||
return '<svg viewBox="0 0 60 70" width="56" height="66" style="vertical-align:middle"><path d="M30 8 C40 26 48 34 38 52 C46 46 46 60 30 64 C14 60 14 46 22 52 C12 34 22 26 30 8 Z" fill="#f97316"/><path d="M30 22 C36 34 40 40 33 52 C39 48 38 58 30 60 C22 58 22 50 26 52 C20 40 26 34 30 22 Z" fill="#fde047"/></svg>';
|
||||
}
|
||||
function mount_p15() {
|
||||
var m = $('p15-burn'); if (!m || m._built) return; m._built = 1;
|
||||
var idx = 0;
|
||||
var idx = 0, anim = null;
|
||||
function stopAnim() { if (anim) { anim.stop(); anim = null; } }
|
||||
function render(){
|
||||
var f = FUELS[idx];
|
||||
stopAnim();
|
||||
m.innerHTML = '<div class="fld"><label>Вещество</label><select id="p15-pick">'
|
||||
+ FUELS.map(function(x,i){ return '<option value="'+i+'"'+(i===idx?' selected':'')+'>'+esc(x.name)+' ('+x.el+')</option>'; }).join('') + '</select>'
|
||||
+ '<button class="btn primary" id="p15-go">Поджечь в кислороде</button></div>'
|
||||
+ '<div id="p15-stage" style="margin:8px 0"></div>'
|
||||
+ '<div class="out" id="p15-out" style="margin-top:8px">Выбери вещество и подожги его в кислороде.</div>';
|
||||
$('p15-pick').addEventListener('change', function(e){ idx=+e.target.value; m._built=0; render(); });
|
||||
$('p15-pick').addEventListener('change', function(e){ idx=+e.target.value; render(); });
|
||||
$('p15-go').addEventListener('click', function(){
|
||||
var out = $('p15-out'); out.className='out ok';
|
||||
out.innerHTML = flame() + ' <b>' + esc(f.name[0].toUpperCase()+f.name.slice(1)) + ' горит в кислороде:</b> ' + esc(f.note) + '.<br>'
|
||||
var f = FUELS[idx], out = $('p15-out');
|
||||
stopAnim();
|
||||
if (W.Chem7Anim) anim = W.Chem7Anim.flameBox($('p15-stage'), { color: f.flame, sparks: f.sparks });
|
||||
out.className='out ok';
|
||||
out.innerHTML = '<b>' + esc(f.name[0].toUpperCase()+f.name.slice(1)) + ' горит в кислороде:</b> ' + esc(f.note) + '.<br>'
|
||||
+ '<div style="margin-top:6px;font-size:1.05rem">' + ceq(f.eq) + '</div>'
|
||||
+ '<div style="font-size:.84rem;color:var(--muted);margin-top:4px">Продукт — <b>оксид</b> (соединение элемента с кислородом).</div>';
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/* chem7_ch3_widgets.js — интерактивы главы 3 «Водород» (Химия 7).
|
||||
* Монтируются движком chem8_engine.js: window.CHEM8_WIDGETS[id].
|
||||
* Используют window.Chem8 (chem8_svg.js): chemEq, formula.
|
||||
* Без эмоджи; KaTeX — через window.chem8RenderMath.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function gcd(a, b) { return b ? gcd(b, a % b) : a; }
|
||||
function ceq(src, opts){ return C().chemEq ? C().chemEq(src, opts || {}) : esc(src); }
|
||||
function fml(s){ return C().formula ? C().formula(s) : s; }
|
||||
var COL = { H:'#cbd5e1', O:'#ef4444' };
|
||||
function molSvg(atoms){
|
||||
var list=[]; atoms.forEach(function(p){ for(var i=0;i<p[1];i++) list.push(p[0]); });
|
||||
var x=22, svg=''; list.forEach(function(el){ x+=16; svg+='<circle cx="'+x+'" cy="30" r="15" fill="'+(COL[el]||'#94a3b8')+'" stroke="rgba(0,0,0,.25)"/><text x="'+x+'" y="35" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">'+el+'</text>'; x+=24; });
|
||||
return '<svg viewBox="0 0 '+(x+10)+' 60" width="100%" style="max-width:'+(x+10)+'px;height:auto">'+svg+'</svg>';
|
||||
}
|
||||
|
||||
/* §18 — модель H₂ + паспорт водорода */
|
||||
function mount_p18() {
|
||||
var m = $('p18-card'); if (!m || m._built) return; m._built = 1;
|
||||
m.innerHTML = molSvg([['H',2]])
|
||||
+ '<div class="out ok"><b>Водород</b><br>Элемент: символ H, $Z=1$, $A_r=1$ — самый лёгкий элемент.<br>'
|
||||
+ 'Простое вещество: молекула $H_2$ — самый лёгкий газ, без цвета и запаха, легче воздуха, мало растворим в воде.<br>'
|
||||
+ 'В природе: в составе воды, многих веществ; во Вселенной — самый распространённый элемент.</div>';
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(m); } catch(e){}
|
||||
}
|
||||
|
||||
/* §19 — реакции водорода: горение и восстановление */
|
||||
var RX = [
|
||||
{ name:'Горение водорода в кислороде', eq:'2H2 + O2 = 2H2O', note:'Водород горит, образуя воду. Смесь водорода с воздухом — «гремучий газ», взрывается!' },
|
||||
{ name:'Восстановление оксида меди(II)', eq:'H2 + CuO = Cu + H2O', note:'Водород отнимает кислород у оксида: чёрный CuO превращается в красную медь. Водород здесь — восстановитель.' }
|
||||
];
|
||||
function mount_p19() {
|
||||
var m = $('p19-rx'); if (!m || m._built) return; m._built = 1;
|
||||
var idx = 0, anim = null;
|
||||
function stopAnim(){ if(anim){anim.stop();anim=null;} }
|
||||
function render(){
|
||||
stopAnim();
|
||||
var r = RX[idx];
|
||||
m.innerHTML = '<div class="fld"><label>Реакция</label><select id="p19-pick">'
|
||||
+ RX.map(function(x,i){ return '<option value="'+i+'"'+(i===idx?' selected':'')+'>'+esc(x.name)+'</option>'; }).join('') + '</select></div>'
|
||||
+ '<div id="p19-stage" style="margin:8px 0"></div>'
|
||||
+ '<div class="out ok" style="margin-top:8px"><div style="font-size:1.05rem">' + ceq(r.eq) + '</div><div style="font-size:.86rem;color:var(--muted);margin-top:6px">' + esc(r.note) + '</div></div>';
|
||||
var stage = $('p19-stage');
|
||||
if (stage && W.Chem7Anim) {
|
||||
if (idx === 1) anim = W.Chem7Anim.colorBlock(stage, '#1f2937', '#b45309', 'CuO (чёрный) → Cu (красная медь)', 1800);
|
||||
else anim = W.Chem7Anim.flameBox(stage, { color: '#93c5fd' });
|
||||
}
|
||||
$('p19-pick').addEventListener('change', function(e){ idx=+e.target.value; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* индикаторы */
|
||||
var ACIDS = [
|
||||
{ f:'HCl', name:'соляная', res:'Cl', resName:'хлорид', resVal:1 },
|
||||
{ f:'H2SO4', name:'серная', res:'SO4', resName:'сульфат', resVal:2 },
|
||||
{ f:'HNO3', name:'азотная', res:'NO3', resName:'нитрат', resVal:1 },
|
||||
{ f:'H2CO3', name:'угольная', res:'CO3', resName:'карбонат',resVal:2 }
|
||||
];
|
||||
var INDIC = {
|
||||
'Лакмус': { neutral:['#7c3aed','фиолетовый'], acid:['#dc2626','красный'] },
|
||||
'Метилоранж': { neutral:['#f59e0b','оранжевый'], acid:['#e11d48','розово-красный'] }
|
||||
};
|
||||
function indicatorWidget(mountId, withAcidPick) {
|
||||
var m = $(mountId); if (!m || m._built) return; m._built = 1;
|
||||
var ind = 'Лакмус', acid = 0, anim = null;
|
||||
function strip(color){ return '<div style="width:120px;height:34px;border-radius:8px;border:1.5px solid var(--border);background:'+color+';display:inline-block;vertical-align:middle"></div>'; }
|
||||
function render(){
|
||||
if (anim) { anim.stop(); anim = null; }
|
||||
var a = ACIDS[acid], col = INDIC[ind];
|
||||
m.innerHTML = '<div class="fld"><label>Индикатор</label><select id="'+mountId+'-ind">'
|
||||
+ Object.keys(INDIC).map(function(k){ return '<option'+(k===ind?' selected':'')+'>'+k+'</option>'; }).join('') + '</select>'
|
||||
+ (withAcidPick ? '<label>Кислота</label><select id="'+mountId+'-acid">' + ACIDS.map(function(x,i){ return '<option value="'+i+'"'+(i===acid?' selected':'')+'>'+fml(x.f)+' ('+x.name+')</option>'; }).join('') + '</select>' : '') + '</div>'
|
||||
+ '<div id="'+mountId+'-drop" style="margin-top:8px"></div>'
|
||||
+ '<div class="out ok" style="margin-top:8px">В нейтральной среде: ' + strip(col.neutral[0]) + ' <b>'+col.neutral[1]+'</b><br>'
|
||||
+ 'В кислоте' + (withAcidPick?(' ('+fml(a.f)+')'):'') + ': ' + strip(col.acid[0]) + ' <b>'+col.acid[1]+'</b></div>';
|
||||
if (W.Chem7Anim) anim = W.Chem7Anim.colorBlock($(mountId+'-drop'), col.neutral[0], col.acid[0], ind + ' в кислоте → ' + col.acid[1], 900);
|
||||
$(mountId+'-ind').addEventListener('change', function(e){ ind=e.target.value; render(); });
|
||||
if (withAcidPick) $(mountId+'-acid').addEventListener('change', function(e){ acid=+e.target.value; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
function mount_p20() {
|
||||
indicatorWidget('p20-ind', true);
|
||||
var t = $('p20-acids'); if (t && !t._built) { t._built = 1;
|
||||
t.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:.9rem"><tr style="background:var(--pri-soft)"><th style="padding:6px;text-align:left">Кислота</th><th style="padding:6px;text-align:left">Название</th><th style="padding:6px;text-align:left">Остаток</th></tr>'
|
||||
+ ACIDS.map(function(a){ return '<tr><td style="padding:6px;border-top:1px solid var(--border)">'+fml(a.f)+'</td><td style="padding:6px;border-top:1px solid var(--border)">'+a.name+'</td><td style="padding:6px;border-top:1px solid var(--border)">'+fml(a.res)+' ('+a.resName+')</td></tr>'; }).join('') + '</table>';
|
||||
}
|
||||
}
|
||||
function mount_lo3() { indicatorWidget('lo3-ind', false); }
|
||||
|
||||
/* §21 — ряд активности металлов */
|
||||
var ROW = ['K','Ca','Na','Mg','Al','Zn','Fe','Ni','Sn','Pb','H','Cu','Hg','Ag','Pt','Au'];
|
||||
function mount_p21() {
|
||||
var m = $('p21-act'); if (!m || m._built) return; m._built = 1;
|
||||
var hIdx = ROW.indexOf('H'), anim = null;
|
||||
function stopAnim(){ if(anim){anim.stop();anim=null;} }
|
||||
m.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:4px">'
|
||||
+ ROW.map(function(el,i){ var isH=el==='H'; return '<button class="act-cell" data-i="'+i+'" style="padding:6px 9px;border-radius:7px;border:1.5px solid '+(isH?'#dc2626':'var(--border)')+';background:'+(isH?'#fee2e2':'var(--card)')+';color:var(--text);font-weight:700;cursor:'+(isH?'default':'pointer')+'">'+(isH?'H₂':el)+'</button>'; }).join('') + '</div>'
|
||||
+ '<div style="font-size:.8rem;color:var(--muted);margin-top:4px">Слева активность убывает вправо. Граница — водород H₂. Кликни металл — «опусти» его в кислоту.</div>'
|
||||
+ '<div id="p21-tube" style="margin-top:8px"></div>'
|
||||
+ '<div class="out" id="p21-act-out" style="margin-top:8px">Кликни по металлу — узнаешь, вытесняет ли он водород из кислоты.</div>';
|
||||
var out = $('p21-act-out');
|
||||
m.querySelectorAll('.act-cell').forEach(function(b){
|
||||
b.addEventListener('click', function(){
|
||||
var i=+b.dataset.i, el=ROW[i], tube=$('p21-tube'); stopAnim();
|
||||
if(el==='H'){ out.className='out'; out.innerHTML='<b>Водород H₂</b> — граница ряда активности.'; if(tube)tube.innerHTML=''; return; }
|
||||
out.className='out ok';
|
||||
if(i<hIdx){
|
||||
var extra = (i<=2) ? ' <span style="color:#dc2626">Внимание: очень активный металл — с кислотами реагирует бурно (для получения водорода используют Zn, Fe).</span>' : '';
|
||||
out.innerHTML = '<b>'+el+'</b> стоит левее H₂ → <b>вытесняет водород</b> из соляной и серной кислот: образуются соль и $H_2\\uparrow$.'+extra;
|
||||
if (tube && W.Chem7Anim) anim = W.Chem7Anim.bubbleField(tube, { color:'rgba(255,255,255,.85)', h:96 });
|
||||
} else {
|
||||
out.innerHTML = '<b>'+el+'</b> стоит правее H₂ → водород из кислот <b>не вытесняет</b> (например, медь и серебро с этими кислотами не реагируют).';
|
||||
if (tube) tube.innerHTML = '<div class="out" style="text-align:center;color:var(--muted)">реакция не идёт — пузырьков нет</div>';
|
||||
}
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(out); } catch(e){}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ЛО4 — взаимодействие кислот с металлами */
|
||||
var L4M = [ ['Zn','цинк',1], ['Fe','железо',1], ['Mg','магний',1], ['Cu','медь',0] ];
|
||||
var L4A = [ ['HCl','соляная'], ['H2SO4','серная'] ];
|
||||
function mount_lo4() {
|
||||
var m = $('lo4-rx'); if (!m || m._built) return; m._built = 1;
|
||||
var mi=0, ai=0;
|
||||
var EQ = { 'Zn|HCl':'Zn + 2HCl = ZnCl2 + H2^', 'Zn|H2SO4':'Zn + H2SO4 = ZnSO4 + H2^',
|
||||
'Fe|HCl':'Fe + 2HCl = FeCl2 + H2^', 'Fe|H2SO4':'Fe + H2SO4 = FeSO4 + H2^',
|
||||
'Mg|HCl':'Mg + 2HCl = MgCl2 + H2^', 'Mg|H2SO4':'Mg + H2SO4 = MgSO4 + H2^' };
|
||||
function render(){
|
||||
m.innerHTML = '<div class="fld"><label>Металл</label><select id="lo4-m">'+L4M.map(function(x,i){return '<option value="'+i+'"'+(i===mi?' selected':'')+'>'+x[1]+' ('+x[0]+')</option>';}).join('')+'</select>'
|
||||
+ '<label>Кислота</label><select id="lo4-a">'+L4A.map(function(x,i){return '<option value="'+i+'"'+(i===ai?' selected':'')+'>'+fml(x[0])+'</option>';}).join('')+'</select>'
|
||||
+ '<button class="btn primary" id="lo4-go">Провести опыт</button></div><div class="out" id="lo4-out" style="margin-top:8px">Выбери металл и кислоту.</div>';
|
||||
$('lo4-m').addEventListener('change',function(e){mi=+e.target.value;m._built=0;render();});
|
||||
$('lo4-a').addEventListener('change',function(e){ai=+e.target.value;m._built=0;render();});
|
||||
$('lo4-go').addEventListener('click',function(){
|
||||
var met=L4M[mi], ac=L4A[ai], out=$('lo4-out');
|
||||
if(!met[2]){ out.className='out bad'; out.innerHTML='<b>'+met[1]+'</b> стоит правее H₂ в ряду активности — реакция <b>не идёт</b>, пузырьки не выделяются.'; return; }
|
||||
out.className='out ok';
|
||||
out.innerHTML='Наблюдаем <b>выделение пузырьков газа</b> (водород $H_2\\uparrow$). Металл вытесняет водород из кислоты:<div style="font-size:1.05rem;margin-top:6px">'+ceq(EQ[met[0]+'|'+ac[0]])+'</div>';
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(out); } catch(e){}
|
||||
});
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* §22 — конструктор солей (металл + кислотный остаток) */
|
||||
var SM = [ ['Na',1], ['K',1], ['Ca',2], ['Mg',2], ['Zn',2], ['Al',3] ];
|
||||
var SR = [ ['Cl',1,'хлорид'], ['NO3',1,'нитрат'], ['SO4',2,'сульфат'], ['CO3',2,'карбонат'] ];
|
||||
function mount_p22() {
|
||||
var m = $('p22-salt'); if (!m || m._built) return; m._built = 1;
|
||||
function render(){
|
||||
m.innerHTML = '<div class="fld"><label>Металл</label><select id="p22-m">'+SM.map(function(x,i){return '<option value="'+i+'"'+(x[0]==='Ca'?' selected':'')+'>'+x[0]+' ('+rom(x[1])+')</option>';}).join('')+'</select>'
|
||||
+ '<label>Остаток</label><select id="p22-r">'+SR.map(function(x,i){return '<option value="'+i+'">'+fml(x[0])+' ('+x[2]+', '+rom(x[1])+')</option>';}).join('')+'</select></div><div class="out" id="p22-out"></div>';
|
||||
$('p22-m').addEventListener('change',upd); $('p22-r').addEventListener('change',upd); upd();
|
||||
}
|
||||
function rom(n){ return ['','I','II','III'][n]; }
|
||||
function upd(){
|
||||
var me=SM[+$('p22-m').value], re=SR[+$('p22-r').value];
|
||||
var lcm=me[1]*re[1]/gcd(me[1],re[1]), x=lcm/me[1], y=lcm/re[1];
|
||||
var poly=/[0-9]/.test(re[0]);
|
||||
var raw = me[0] + (x>1?x:'') + (poly && y>1 ? '('+re[0]+')'+y : re[0] + (y>1?y:''));
|
||||
var out=$('p22-out'); out.className='out ok';
|
||||
out.innerHTML='<span class="bd">Валентности: '+me[0]+' = '+rom(me[1])+', остаток '+fml(re[0])+' = '+rom(re[1])+'<br>Формула соли ('+re[2]+'а): <b style="font-size:1.15rem">'+fml(raw)+'</b></span>';
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* ПР3 — чистота водорода («гремучий газ») */
|
||||
function mount_pr3() {
|
||||
var m = $('pr3-test'); if (!m || m._built) return; m._built = 1;
|
||||
m.innerHTML = '<div class="fld"><button class="btn primary" id="pr3-mix">Поджечь смесь H₂ с воздухом</button><button class="btn" id="pr3-pure">Поджечь чистый H₂</button></div><div class="out" id="pr3-out" style="margin-top:8px">Чтобы проверить чистоту водорода, его поджигают.</div>';
|
||||
$('pr3-mix').addEventListener('click',function(){ var o=$('pr3-out'); o.className='out bad'; o.innerHTML='Смесь водорода с воздухом — «<b>гремучий газ</b>» — взрывается с резким <b>хлопком</b>. Значит, водород собран нечисто.'; });
|
||||
$('pr3-pure').addEventListener('click',function(){ var o=$('pr3-out'); o.className='out ok'; o.innerHTML='Чистый водород горит <b>спокойно</b>, почти без звука. Значит, газ собран чисто.'; });
|
||||
}
|
||||
|
||||
W.CHEM8_WIDGETS = Object.assign(W.CHEM8_WIDGETS || {}, {
|
||||
p18: mount_p18, p19: mount_p19, p20: mount_p20, lo3: mount_lo3,
|
||||
p21: mount_p21, lo4: mount_lo4, p22: mount_p22, pr3: mount_pr3
|
||||
});
|
||||
W.FLAG_MOUNTS = Object.assign(W.FLAG_MOUNTS || {}, {});
|
||||
})(window);
|
||||
@@ -0,0 +1,159 @@
|
||||
/* chem7_ch4_widgets.js — интерактивы главы 4 «Вода» (Химия 7).
|
||||
* Монтируются движком chem8_engine.js: window.CHEM8_WIDGETS[id].
|
||||
* Используют window.Chem8 (chem8_svg.js): chemEq, formula.
|
||||
* Без эмоджи; KaTeX — через window.chem8RenderMath.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function gcd(a, b) { return b ? gcd(b, a % b) : a; }
|
||||
function ceq(src, opts){ return C().chemEq ? C().chemEq(src, opts || {}) : esc(src); }
|
||||
function fml(s){ return C().formula ? C().formula(s) : s; }
|
||||
function strip(color){ return '<div style="width:120px;height:32px;border-radius:8px;border:1.5px solid var(--border);background:'+color+';display:inline-block;vertical-align:middle"></div>'; }
|
||||
|
||||
/* §23 — разложение воды (2:1) + реакции воды */
|
||||
var WRX = [
|
||||
{ name:'Разложение электрическим током', eq:'2H2O = 2H2^ + O2^', cond:'эл. ток', note:'Вода разлагается на простые вещества: водорода получается вдвое больше по объёму, чем кислорода (2 : 1).' },
|
||||
{ name:'Реакция с натрием', eq:'2Na + 2H2O = 2NaOH + H2^', note:'Активные металлы реагируют с водой, образуя щёлочь и водород.' },
|
||||
{ name:'Реакция с оксидом кальция', eq:'CaO + H2O = Ca(OH)2', note:'Оксиды активных металлов с водой дают основания.' },
|
||||
{ name:'Реакция с углекислым газом', eq:'CO2 + H2O = H2CO3', note:'Оксиды неметаллов с водой дают кислоты.' }
|
||||
];
|
||||
function decompSvg(){
|
||||
// две перевёрнутые пробирки: H2 (заполнена на 2/2), O2 (на 1/2)
|
||||
return '<svg viewBox="0 0 220 140" width="100%" style="max-width:240px">'
|
||||
+ '<rect x="40" y="20" width="34" height="90" rx="6" fill="#dbeafe" stroke="#93c5fd"/>'
|
||||
+ '<rect x="40" y="20" width="34" height="74" rx="6" fill="#bfdbfe"/>'
|
||||
+ '<text x="57" y="125" text-anchor="middle" font-size="13" font-weight="700" fill="#1d4ed8">H₂</text>'
|
||||
+ '<text x="57" y="58" text-anchor="middle" font-size="11" fill="#1e3a8a">2 V</text>'
|
||||
+ '<rect x="146" y="20" width="34" height="90" rx="6" fill="#fee2e2" stroke="#fca5a5"/>'
|
||||
+ '<rect x="146" y="65" width="34" height="45" rx="6" fill="#fecaca"/>'
|
||||
+ '<text x="163" y="125" text-anchor="middle" font-size="13" font-weight="700" fill="#b91c1c">O₂</text>'
|
||||
+ '<text x="163" y="90" text-anchor="middle" font-size="11" fill="#7f1d1d">1 V</text>'
|
||||
+ '<rect x="20" y="110" width="180" height="12" rx="4" fill="#60a5fa"/>'
|
||||
+ '</svg>';
|
||||
}
|
||||
function mount_p23() {
|
||||
var m = $('p23-water'); if (!m || m._built) return; m._built = 1;
|
||||
var idx = 0, anims = [];
|
||||
function stopAnim(){ anims.forEach(function(a){ try { a.stop(); } catch(e){} }); anims = []; }
|
||||
function render(){
|
||||
stopAnim();
|
||||
var r = WRX[idx];
|
||||
m.innerHTML = (idx===0 ? decompSvg()
|
||||
+ '<div style="display:flex;gap:10px;margin-top:6px"><div style="flex:1"><div style="text-align:center;font-size:.76rem;font-weight:700;color:#1d4ed8">H₂ — 2 объёма</div><div id="p23-bub-h"></div></div>'
|
||||
+ '<div style="flex:1"><div style="text-align:center;font-size:.76rem;font-weight:700;color:#b91c1c">O₂ — 1 объём</div><div id="p23-bub-o"></div></div></div>' : '')
|
||||
+ '<div class="fld"><label>Реакция воды</label><select id="p23-pick">'
|
||||
+ WRX.map(function(x,i){ return '<option value="'+i+'"'+(i===idx?' selected':'')+'>'+esc(x.name)+'</option>'; }).join('') + '</select></div>'
|
||||
+ '<div class="out ok" style="margin-top:8px"><div style="font-size:1.05rem">'+ceq(r.eq,{cond:r.cond})+'</div>'
|
||||
+ '<div style="font-size:.86rem;color:var(--muted);margin-top:6px">'+esc(r.note)+'</div></div>';
|
||||
if (idx===0 && W.Chem7Anim) {
|
||||
anims.push(W.Chem7Anim.bubbleField($('p23-bub-h'), { color:'rgba(96,165,250,.9)', count:18, h:84, bg:'linear-gradient(180deg,#dbeafe,transparent)' }));
|
||||
anims.push(W.Chem7Anim.bubbleField($('p23-bub-o'), { color:'rgba(248,113,113,.9)', count:9, h:84, bg:'linear-gradient(180deg,#fee2e2,transparent)' }));
|
||||
}
|
||||
$('p23-pick').addEventListener('change', function(e){ idx=+e.target.value; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* индикаторы в щёлочи */
|
||||
var ALK_IND = {
|
||||
'Лакмус': { neutral:['#7c3aed','фиолетовый'], alk:['#2563eb','синий'] },
|
||||
'Фенолфталеин': { neutral:['#f3f4f6','бесцветный'], alk:['#db2777','малиновый'] },
|
||||
'Метилоранж': { neutral:['#f59e0b','оранжевый'], alk:['#eab308','жёлтый'] }
|
||||
};
|
||||
function alkIndicator(mountId) {
|
||||
var m = $(mountId); if (!m || m._built) return; m._built = 1;
|
||||
var ind = 'Фенолфталеин', anim = null;
|
||||
function render(){
|
||||
if (anim) { anim.stop(); anim = null; }
|
||||
var c = ALK_IND[ind];
|
||||
m.innerHTML = '<div class="fld"><label>Индикатор</label><select id="'+mountId+'-sel">'
|
||||
+ Object.keys(ALK_IND).map(function(k){ return '<option'+(k===ind?' selected':'')+'>'+k+'</option>'; }).join('') + '</select></div>'
|
||||
+ '<div id="'+mountId+'-drop" style="margin-top:8px"></div>'
|
||||
+ '<div class="out ok" style="margin-top:8px">В нейтральной среде: ' + strip(c.neutral[0]) + ' <b>'+c.neutral[1]+'</b><br>'
|
||||
+ 'В щёлочи: ' + strip(c.alk[0]) + ' <b>'+c.alk[1]+'</b></div>';
|
||||
if (W.Chem7Anim) anim = W.Chem7Anim.colorBlock($(mountId+'-drop'), c.neutral[0], c.alk[0], ind + ' в щёлочи → ' + c.alk[1], 900);
|
||||
$(mountId+'-sel').addEventListener('change', function(e){ ind=e.target.value; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/* §24 — конструктор оснований Me(OH)n + индикаторы */
|
||||
var BM = [ ['Na',1], ['K',1], ['Ca',2], ['Mg',2], ['Cu',2], ['Al',3], ['Fe',3] ];
|
||||
var SOLUBLE = { Na:1, K:1, Ca:1 };
|
||||
function mount_p24() {
|
||||
var b = $('p24-bld');
|
||||
if (b && !b._built) { b._built = 1;
|
||||
function rom(n){ return ['','I','II','III'][n]; }
|
||||
b.innerHTML = '<div class="fld"><label>Металл</label><select id="p24-m">'
|
||||
+ BM.map(function(e,i){ return '<option value="'+i+'"'+(e[0]==='Na'?' selected':'')+'>'+e[0]+' ('+rom(e[1])+')</option>'; }).join('') + '</select> + гидроксогруппа OH (I)</div><div class="out" id="p24-out"></div>';
|
||||
function upd(){
|
||||
var e=BM[+$('p24-m').value], n=e[1];
|
||||
var raw = e[0] + (n>1 ? '(OH)'+n : 'OH');
|
||||
var sol = SOLUBLE[e[0]] ? 'щёлочь (растворимое основание)' : 'нерастворимое основание';
|
||||
var out=$('p24-out'); out.className='out ok';
|
||||
out.innerHTML='<span class="bd">Валентность '+e[0]+' = '+rom(n)+', OH = I → '+n+' группы OH<br>Формула основания: <b style="font-size:1.15rem">'+fml(raw)+'</b><br>Это '+sol+'.</span>';
|
||||
}
|
||||
$('p24-m').addEventListener('change',upd); upd();
|
||||
}
|
||||
alkIndicator('p24-ind');
|
||||
}
|
||||
function mount_lo5() { alkIndicator('lo5-ind'); }
|
||||
|
||||
/* §25 / ПР4 — нейтрализация (фенолфталеин малиновый → бесцветный) */
|
||||
function mount_neutral(mountId) {
|
||||
var m = $(mountId); if (!m || m._built) return; m._built = 1;
|
||||
var done = false, anim = null;
|
||||
function render(){
|
||||
if (anim) { anim.stop(); anim = null; }
|
||||
m.innerHTML = '<div id="'+mountId+'-cup" style="margin-bottom:8px"></div>'
|
||||
+ '<div style="font-size:.92rem">'+(done
|
||||
? 'Раствор стал <b>бесцветным</b> — кислота нейтрализовала щёлочь. Реакция завершена.'
|
||||
: 'В щёлочи с фенолфталеином раствор <b>малиновый</b>. Добавляй кислоту по каплям.')+'</div>'
|
||||
+ '<div class="fld" style="margin-top:8px"><button class="btn primary" id="'+mountId+'-go">'+(done?'Сбросить':'Добавить кислоту')+'</button></div>'
|
||||
+ (done ? '<div class="out ok" style="margin-top:8px"><div style="font-size:1.05rem">'+ceq('HCl + NaOH = NaCl + H2O')+'</div><div style="font-size:.84rem;color:var(--muted);margin-top:4px">Кислота + основание → соль + вода. Это реакция <b>нейтрализации</b>.</div></div>' : '');
|
||||
if (W.Chem7Anim) anim = done
|
||||
? W.Chem7Anim.colorBlock($(mountId+'-cup'), '#db2777', '#f8fafc', 'малиновый → бесцветный', 1600)
|
||||
: W.Chem7Anim.colorBlock($(mountId+'-cup'), '#db2777', '#db2777', 'щёлочь + фенолфталеин', 1);
|
||||
$(mountId+'-go').addEventListener('click', function(){ done=!done; render(); });
|
||||
}
|
||||
render();
|
||||
}
|
||||
function mount_p25() { mount_neutral('p25-neu'); }
|
||||
function mount_pr4() { mount_neutral('pr4-neu'); }
|
||||
|
||||
/* §26 — охрана воды и воздуха: источники загрязнения и способы охраны */
|
||||
var ECO = {
|
||||
'Источники загрязнения': [
|
||||
['Промышленные выбросы','Газы и пыль из труб заводов загрязняют воздух.'],
|
||||
['Сточные воды','Неочищенные стоки отравляют реки и озёра.'],
|
||||
['Нефть','Разливы нефти губят водные организмы.'],
|
||||
['Кислотные дожди','Оксиды серы и азота в воздухе образуют кислоты, выпадающие с дождём.']
|
||||
],
|
||||
'Способы охраны и очистки': [
|
||||
['Очистные сооружения','Сточные воды очищают перед сбросом.'],
|
||||
['Фильтрование','На водопроводных станциях удаляют твёрдые частицы.'],
|
||||
['Хлорирование и озонирование','Обеззараживают питьевую воду.'],
|
||||
['Бережное отношение','Экономить воду и не загрязнять водоёмы.']
|
||||
]
|
||||
};
|
||||
function mount_p26() {
|
||||
var m = $('p26-eco'); if (!m || m._built) return; m._built = 1;
|
||||
var cols = Object.keys(ECO).map(function(title){
|
||||
var items = ECO[title].map(function(it,i){ return '<button class="eco-it btn" data-t="'+esc(title)+'" data-i="'+i+'" style="display:block;width:100%;text-align:left;margin:4px 0">'+esc(it[0])+'</button>'; }).join('');
|
||||
return '<div style="flex:1;min-width:200px"><div style="font-weight:700;margin-bottom:6px">'+esc(title)+'</div>'+items+'</div>';
|
||||
}).join('');
|
||||
m.innerHTML = '<div style="display:flex;gap:14px;flex-wrap:wrap">'+cols+'</div><div class="out" id="p26-eco-out" style="margin-top:8px">Кликни по пункту, чтобы узнать подробнее.</div>';
|
||||
var out=$('p26-eco-out');
|
||||
m.querySelectorAll('.eco-it').forEach(function(b){
|
||||
b.addEventListener('click', function(){ var it=ECO[b.dataset.t][+b.dataset.i]; out.className='out ok'; out.innerHTML='<b>'+esc(it[0])+'.</b> '+esc(it[1]); });
|
||||
});
|
||||
}
|
||||
|
||||
W.CHEM8_WIDGETS = Object.assign(W.CHEM8_WIDGETS || {}, {
|
||||
p23: mount_p23, p24: mount_p24, lo5: mount_lo5, p25: mount_p25, pr4: mount_pr4, p26: mount_p26
|
||||
});
|
||||
W.FLAG_MOUNTS = Object.assign(W.FLAG_MOUNTS || {}, {});
|
||||
})(window);
|
||||
@@ -0,0 +1,754 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
DASHBOARD · ADMIN COMMAND CENTER
|
||||
Рендерит «командный центр администратора» в /dashboard для роли admin.
|
||||
Дизайн — порт макета frontend/admin-dashboard-redesign.html
|
||||
(cobalt accent · Hanken Grotesk · JetBrains Mono · hairline borders),
|
||||
но на реальных данных GET /api/admin/overview.
|
||||
Весь CSS заскоуплен под #admin-command-center, чтобы не конфликтовать
|
||||
с ls.css / dashboard.html. Деструктивные действия не выполняются
|
||||
inline — кнопки ведут в соответствующие разделы /admin.
|
||||
Точка входа: window.DashAdminCenter.mount(rootEl).
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let _root = null;
|
||||
let _data = null;
|
||||
let _tab = 'all';
|
||||
let _clockTimer = null;
|
||||
|
||||
/* ── subject hue cycle (совпадает с overview.js) ──────────────── */
|
||||
const SUBJ_COLORS = [
|
||||
'#3558e0', '#0ea5b7', '#7c3aed', '#d97706', '#16a34a',
|
||||
'#e11d48', '#4FC3F7', '#FFD54F', '#FF8A65', '#BA68C8',
|
||||
];
|
||||
|
||||
/* ── one-time font + CSS injection ────────────────────────────── */
|
||||
function ensureAssets() {
|
||||
if (!document.getElementById('acc-font')) {
|
||||
const l = document.createElement('link');
|
||||
l.id = 'acc-font';
|
||||
l.rel = 'stylesheet';
|
||||
l.href = 'https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,300..800;1,400..600&family=JetBrains+Mono:wght@400;500;600;700&display=swap';
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
if (document.getElementById('acc-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'acc-style';
|
||||
s.textContent = CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────── */
|
||||
const e = (str) => (window.LS && LS.esc ? LS.esc(str) : String(str == null ? '' : str));
|
||||
|
||||
function initials(name) {
|
||||
if (!name) return '?';
|
||||
return name.trim().split(/\s+/).slice(0, 2)
|
||||
.map((w) => (w[0] ? w[0].toUpperCase() : '')).join('') || '?';
|
||||
}
|
||||
function hashHue(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (str || '').length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) % 360;
|
||||
}
|
||||
function pctClass(p) {
|
||||
if (p == null) return 'mid';
|
||||
return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo';
|
||||
}
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '0';
|
||||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
function parseTs(s) {
|
||||
if (!s) return null;
|
||||
try { return new Date(s.replace(' ', 'T') + (s.endsWith('Z') ? '' : 'Z')); }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function fmtAgo(s) {
|
||||
const d = parseTs(s);
|
||||
if (!d) return '';
|
||||
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (sec < 60) return 'только что';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + ' мин';
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return hr + ' ч';
|
||||
return Math.floor(hr / 24) + ' дн';
|
||||
}
|
||||
function fmtSince(s) {
|
||||
const d = parseTs(s);
|
||||
if (!d) return '—';
|
||||
let min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||
if (min < 0) min = 0;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr >= 48) return Math.floor(hr / 24) + 'д ' + (hr % 24) + 'ч';
|
||||
return hr > 0 ? hr + 'ч ' + (min % 60) + 'м' : min + 'м';
|
||||
}
|
||||
function fmtBannedDate(s) {
|
||||
const d = parseTs(s);
|
||||
if (!d) return '';
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
function go(hash) { window.location.href = hash; }
|
||||
|
||||
/* ── sparkline path (array of {d, n} → 7-day path) ────────────── */
|
||||
function sparkPath(raw, w, h) {
|
||||
const map = {};
|
||||
(raw || []).forEach((r) => { map[r.d] = r.n; });
|
||||
const pts = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dt = new Date();
|
||||
dt.setDate(dt.getDate() - i);
|
||||
pts.push(map[dt.toISOString().slice(0, 10)] || 0);
|
||||
}
|
||||
const max = Math.max.apply(null, pts) || 1;
|
||||
const pad = 2;
|
||||
const xs = pts.map((_, i) => pad + (i / 6) * (w - 2 * pad));
|
||||
const ys = pts.map((v) => h - pad - (v / max) * (h - 2 * pad));
|
||||
return xs.map((x, i) => x.toFixed(1) + ' ' + ys[i].toFixed(1)).join(' L ');
|
||||
}
|
||||
|
||||
/* ── greeting by hour ─────────────────────────────────────────── */
|
||||
function greeting() {
|
||||
const hr = new Date().getHours();
|
||||
if (hr < 6) return 'Доброй ночи';
|
||||
if (hr < 12) return 'Доброе утро';
|
||||
if (hr < 18) return 'Добрый день';
|
||||
return 'Добрый вечер';
|
||||
}
|
||||
function clockStr() {
|
||||
const d = new Date();
|
||||
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function dateStr() {
|
||||
const d = new Date();
|
||||
const s = d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/* ── KPI pulse row ────────────────────────────────────────────── */
|
||||
function kpiRow(d) {
|
||||
const sp = d.sparks || {};
|
||||
const sessSpark = sparkPath(sp.sessions, 120, 34);
|
||||
return `
|
||||
<section class="acc-pulse">
|
||||
<div class="acc-kpi hero">
|
||||
<div class="acc-kpi-top">
|
||||
<div class="acc-kpi-ic b"><svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
|
||||
<span class="acc-lbl">Сессий запущено · 24ч</span>
|
||||
<span class="acc-live"><span class="acc-dot"></span>LIVE</span>
|
||||
</div>
|
||||
<div class="acc-num">${fmtNum(d.newSessions24h)}</div>
|
||||
<div class="acc-kpi-foot">за последние сутки</div>
|
||||
<svg class="acc-spark" viewBox="0 0 120 34" preserveAspectRatio="none">
|
||||
<path class="acc-line" d="M ${sessSpark}" stroke="#3558e0"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="acc-kpi">
|
||||
<div class="acc-kpi-top">
|
||||
<div class="acc-kpi-ic g"><svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M4 18V7M9 18V4M14 18v-7M19 18V9"/></svg></div>
|
||||
<span class="acc-lbl">Активных юзеров</span>
|
||||
</div>
|
||||
<div class="acc-num">${fmtNum(d.activeUsers24h)}</div>
|
||||
<svg class="acc-spark" viewBox="0 0 74 26" preserveAspectRatio="none">
|
||||
<path class="acc-line" d="M ${sparkPath(sp.active, 74, 26)}" stroke="#16a34a"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="acc-kpi">
|
||||
<div class="acc-kpi-top">
|
||||
<div class="acc-kpi-ic c"><svg class="acc-ic sm" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.4"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M18 7v6M21 10h-6"/></svg></div>
|
||||
<span class="acc-lbl">Новых за 24ч</span>
|
||||
</div>
|
||||
<div class="acc-num">${fmtNum(d.newUsers24h)}</div>
|
||||
<svg class="acc-spark" viewBox="0 0 74 26" preserveAspectRatio="none">
|
||||
<path class="acc-line" d="M ${sparkPath(sp.users, 74, 26)}" stroke="#0ea5b7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="acc-kpi">
|
||||
<div class="acc-kpi-top">
|
||||
<div class="acc-kpi-ic v"><svg class="acc-ic sm" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M3 9h18M8 4.5v3M16 4.5v3"/></svg></div>
|
||||
<span class="acc-lbl">Всего классов</span>
|
||||
</div>
|
||||
<div class="acc-num">${fmtNum(d.classesTotal)}</div>
|
||||
<div class="acc-kpi-foot">активных учебных групп</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ── attention inbox items (unified queue) ────────────────────── */
|
||||
function buildAttnItems(d) {
|
||||
const items = [];
|
||||
(d.bannedThisWeek || []).forEach((u) => {
|
||||
items.push({
|
||||
sev: 'rose', kind: 'block', kindLabel: 'Блокировка',
|
||||
title: u.name || '—',
|
||||
meta: `<span class="acc-mono">${e(u.email || '')}</span> · ${fmtBannedDate(u.banned_at)}`,
|
||||
act: 'Разблокировать', actHash: '/admin#users', solid: false,
|
||||
});
|
||||
});
|
||||
(d.stuckSessions || []).forEach((s) => {
|
||||
items.push({
|
||||
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
|
||||
title: s.user_name || '—',
|
||||
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
|
||||
act: 'Открыть', actHash: '/admin#sessions', solid: true,
|
||||
});
|
||||
});
|
||||
const ab = d.abandonedSessions24h || 0;
|
||||
if (ab > 0) {
|
||||
items.push({
|
||||
sev: 'amber', kind: 'stuck', kindLabel: 'Брошено',
|
||||
title: 'Всплеск брошенных сессий',
|
||||
meta: `<span class="acc-mono">${ab}</span> сессий прервано за 24ч`,
|
||||
act: 'Разобрать', actHash: '/admin#sessions', solid: false,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function attnRowHtml(it) {
|
||||
const icon = it.sev === 'rose'
|
||||
? '<svg class="acc-ic sm" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.4"/><path d="M5 20a7 7 0 0 1 14 0"/><path d="M18 5l3 3M21 5l-3 3"/></svg>'
|
||||
: '<svg class="acc-ic sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5l3 2"/></svg>';
|
||||
return `
|
||||
<div class="acc-attn-row">
|
||||
<div class="acc-sev ${it.sev}">${icon}</div>
|
||||
<div class="acc-attn-main">
|
||||
<div class="acc-a-row1"><span class="acc-kind ${it.sev}">${e(it.kindLabel)}</span><h4>${e(it.title)}</h4></div>
|
||||
<div class="acc-attn-meta">${it.meta}</div>
|
||||
</div>
|
||||
<button class="acc-attn-act${it.solid ? ' solid' : ''}" data-go="${it.actHash}">${e(it.act)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function attnCard(d) {
|
||||
const items = buildAttnItems(d);
|
||||
const blocks = items.filter((i) => i.kind === 'block');
|
||||
const stuck = items.filter((i) => i.kind === 'stuck');
|
||||
let shown = items;
|
||||
if (_tab === 'block') shown = blocks;
|
||||
else if (_tab === 'stuck') shown = stuck;
|
||||
|
||||
const body = shown.length
|
||||
? `<div class="acc-attn-list">${shown.map(attnRowHtml).join('')}</div>`
|
||||
: `<div class="acc-attn-empty">
|
||||
<svg class="acc-ic" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<b>Всё в норме</b><span>нет событий, требующих внимания</span>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<section class="acc-card acc-attn">
|
||||
<div class="acc-card-head">
|
||||
<div class="acc-ttl-ic"><svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17h.01"/></svg></div>
|
||||
<h2>Требует внимания</h2>
|
||||
<span class="acc-count">${items.length} ${items.length === 1 ? 'событие' : 'событий'}</span>
|
||||
<span class="acc-more" data-go="/admin#sessions">все алерты <svg class="acc-ic xs" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</div>
|
||||
<div class="acc-attn-tabs">
|
||||
<button class="acc-attn-tab${_tab === 'all' ? ' on' : ''}" data-tab="all">Все <span class="acc-tag">${items.length}</span></button>
|
||||
<button class="acc-attn-tab${_tab === 'block' ? ' on' : ''}" data-tab="block">Блокировки <span class="acc-tag rose">${blocks.length}</span></button>
|
||||
<button class="acc-attn-tab${_tab === 'stuck' ? ' on' : ''}" data-tab="stuck">Зависшие <span class="acc-tag amber">${stuck.length}</span></button>
|
||||
</div>
|
||||
${body}
|
||||
<div class="acc-attn-foot">
|
||||
<span>Единая очередь действий вместо разрозненных карточек.</span>
|
||||
<span><b>${items.length}</b> в очереди</span>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ── live feed (top sessions) ─────────────────────────────────── */
|
||||
function feedCard(d) {
|
||||
const rows = (d.topSessions24h || []).slice(0, 8);
|
||||
const subj = d.sessionsBySubject24h || [];
|
||||
const total = subj.reduce((a, r) => a + r.n, 0) || 1;
|
||||
|
||||
const feedHtml = rows.length ? rows.map((s) => {
|
||||
const name = s.user_name || '—';
|
||||
const pc = s.percent;
|
||||
return `
|
||||
<div class="acc-feed-row">
|
||||
<div class="acc-feed-av" style="background:hsl(${hashHue(name)},55%,55%)">${e(initials(name))}</div>
|
||||
<div class="acc-feed-main">
|
||||
<b>${e(name)}</b>
|
||||
<div class="acc-f-meta">${e(s.subject_name || '—')} · ${fmtAgo(s.finished_at)}</div>
|
||||
</div>
|
||||
<div class="acc-feed-right">
|
||||
<div class="acc-feed-pct ${pctClass(pc)}">${pc != null ? pc : '—'}%</div>
|
||||
<div class="acc-feed-ago">${(s.score != null ? s.score : 0)}/${(s.total != null ? s.total : 0)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="acc-attn-empty" style="padding:30px 16px"><span>Нет завершённых сессий за 24ч</span></div>';
|
||||
|
||||
let segs = '', legend = '';
|
||||
subj.forEach((r, i) => {
|
||||
const pct = (r.n / total * 100).toFixed(1);
|
||||
const col = SUBJ_COLORS[i % SUBJ_COLORS.length];
|
||||
segs += `<div class="acc-seg" style="width:${pct}%;background:${col}" title="${e(r.name)}: ${r.n}"></div>`;
|
||||
legend += `<span><span class="acc-subj-dot" style="background:${col}"></span>${e(r.name)} <b>${r.n}</b></span>`;
|
||||
});
|
||||
const subjBlock = subj.length ? `
|
||||
<div class="acc-subj-mini">
|
||||
<div class="acc-sm-head"><span>Сессии по предметам · 24ч</span><b>${total}</b></div>
|
||||
<div class="acc-subj-track">${segs}</div>
|
||||
<div class="acc-subj-legend">${legend}</div>
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<section class="acc-card">
|
||||
<div class="acc-card-head">
|
||||
<div class="acc-ttl-ic" style="background:var(--acc-green-50)"><svg class="acc-ic sm" style="stroke:var(--acc-green)" viewBox="0 0 24 24"><path d="M4 18V7M9 18V4M14 18v-7M19 18V9"/></svg></div>
|
||||
<h2>Топ сегодня</h2>
|
||||
<span class="acc-more" data-go="/admin#sessions">все сессии <svg class="acc-ic xs" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</div>
|
||||
<div class="acc-feed">${feedHtml}</div>
|
||||
${subjBlock}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ── content health ───────────────────────────────────────────── */
|
||||
function healthRow(d) {
|
||||
const inv = d.inventory || {};
|
||||
const card = (icon, n, lbl) => `
|
||||
<div class="acc-hcard">
|
||||
<div class="acc-hcard-top">
|
||||
<div class="acc-hcard-ic">${icon}</div>
|
||||
<span class="acc-lbl">${lbl}</span>
|
||||
</div>
|
||||
<div class="acc-hn">${fmtNum(n != null ? n : 0)}</div>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="acc-sec-title"><span>Здоровье контента</span><span class="acc-ln"></span></div>
|
||||
<div class="acc-health">
|
||||
${card('<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.3c-.8.4-1.4.9-1.4 1.7v.4"/><path d="M11.5 17h.01"/><circle cx="12" cy="12" r="9"/></svg>', inv.questions, 'вопросов')}
|
||||
${card('<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M9 5h7a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/><path d="M9 9h6M9 13h6M9 17h4"/></svg>', inv.tests, 'тестов')}
|
||||
${card('<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z"/><path d="M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg>', inv.courses, 'курсов')}
|
||||
${card('<svg class="acc-ic sm" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M3 9h18M8 4.5v3M16 4.5v3"/></svg>', inv.classes, 'классов')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── results tables (top / worst) ─────────────────────────────── */
|
||||
function resTable(rows) {
|
||||
if (!rows || !rows.length) return '<div class="acc-attn-empty" style="padding:26px 14px"><span>Нет данных за 24ч</span></div>';
|
||||
const body = rows.slice(0, 5).map((s) => {
|
||||
const name = s.user_name || '—';
|
||||
return `<tr>
|
||||
<td><div class="acc-rt-user"><span class="acc-rt-av" style="background:hsl(${hashHue(name)},55%,55%)">${e(initials(name))}</span>${e(name)}</div></td>
|
||||
<td><span class="acc-rt-subj">${e(s.subject_name || '—')}</span></td>
|
||||
<td class="r"><span class="acc-rt-score">${(s.score != null ? s.score : 0)}/${(s.total != null ? s.total : 0)}</span></td>
|
||||
<td class="r"><span class="acc-rt-pct ${pctClass(s.percent)}">${s.percent != null ? s.percent : '—'}%</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<table class="acc-rtable">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th class="r">Счёт</th><th class="r">%</th></tr></thead>
|
||||
<tbody>${body}</tbody></table>`;
|
||||
}
|
||||
function resultsRow(d) {
|
||||
return `
|
||||
<div class="acc-sec-title"><span>Результаты · 24ч</span><span class="acc-ln"></span></div>
|
||||
<div class="acc-results">
|
||||
<section class="acc-card">
|
||||
<div class="acc-card-head"><h2>Топ-5 сегодня</h2></div>
|
||||
${resTable(d.topSessions24h)}
|
||||
</section>
|
||||
<section class="acc-card">
|
||||
<div class="acc-card-head"><h2>Худшие 5 сегодня</h2></div>
|
||||
${resTable(d.worstSessions24h)}
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── quick actions ────────────────────────────────────────────── */
|
||||
function quickRow() {
|
||||
const btn = (icon, title, sub, hash) => `
|
||||
<button class="acc-qbtn" data-go="${hash}">
|
||||
<div class="acc-qbtn-ic">${icon}</div>
|
||||
<b>${title}</b><span>${sub}</span>
|
||||
</button>`;
|
||||
return `
|
||||
<div class="acc-sec-title"><span>Быстрые действия</span><span class="acc-ln"></span></div>
|
||||
<div class="acc-quick">
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.4"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><circle cx="17.5" cy="9" r="2.6"/><path d="M16 14.6A4.6 4.6 0 0 1 21 19"/></svg>', 'Пользователи', '#users', '/admin#users')}
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5l3.5 2"/></svg>', 'Сессии', '#sessions', '/admin#sessions')}
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M9 5h7a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/><path d="M9 9h6M9 13h6"/></svg>', 'Тесты', '#tests', '/admin#tests')}
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M3 9h18M8 4.5v3M16 4.5v3"/></svg>', 'Классы', '#classes', '/classes')}
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>', 'Права', '#permissions', '/admin#permissions')}
|
||||
${btn('<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M6 3h9l3 3v15H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M9 9h6M9 13h6M9 17h4"/></svg>', 'Аудит-лог', '#sublog', '/admin#sublog')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── header (page-head + topbar strip) ────────────────────────── */
|
||||
function headHtml(d) {
|
||||
const items = buildAttnItems(d);
|
||||
const blocks = items.filter((i) => i.kind === 'block').length;
|
||||
const stuck = (d.stuckSessions || []).length;
|
||||
const ab = d.abandonedSessions24h || 0;
|
||||
let sub;
|
||||
if (!items.length) {
|
||||
sub = 'Сегодня всё спокойно — событий, требующих внимания, нет.';
|
||||
} else {
|
||||
const parts = [];
|
||||
if (blocks) parts.push(`<b>${blocks}</b> ${blocks === 1 ? 'блокировка' : 'блокировок'}`);
|
||||
if (stuck) parts.push(`<b>${stuck}</b> ${stuck === 1 ? 'зависшая сессия' : 'зависших'}`);
|
||||
if (ab) parts.push(`<b>${ab}</b> брошенных`);
|
||||
sub = 'Требует внимания: ' + parts.join(', ') + '.';
|
||||
}
|
||||
return `
|
||||
<header class="acc-topbar">
|
||||
<div class="acc-crumbs">
|
||||
<span>Дашборд</span>
|
||||
<svg class="acc-ic xs" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
|
||||
<span class="here">Командный центр</span>
|
||||
</div>
|
||||
<div class="acc-clock"><span id="acc-date">${dateStr()}</span> · <b id="acc-clock">${clockStr()}</b></div>
|
||||
<div class="acc-tb-right">
|
||||
<button class="acc-tb-icon" data-act="refresh" title="Обновить">
|
||||
<svg class="acc-ic" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-1.5 5.5"/><path d="M20 5v6h-6"/></svg>
|
||||
</button>
|
||||
<button class="acc-btn" data-go="/admin">
|
||||
<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||
Полная админка
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="acc-page-head">
|
||||
<div>
|
||||
<div class="acc-kicker">
|
||||
<svg class="acc-ic sm" viewBox="0 0 24 24"><path d="M4 14l5-5 3 3 4-6 4 5"/><path d="M4 19h16"/></svg>
|
||||
<span>${greeting()}</span><span class="muted">· сводка за 24 часа</span>
|
||||
</div>
|
||||
<h1>Командный <span>центр</span></h1>
|
||||
<p class="acc-sub">${sub}</p>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ── full render ──────────────────────────────────────────────── */
|
||||
function render() {
|
||||
if (!_root || !_data) return;
|
||||
const d = _data;
|
||||
_root.innerHTML = `
|
||||
<div class="acc-shell">
|
||||
${headHtml(d)}
|
||||
${kpiRow(d)}
|
||||
<div class="acc-grid">
|
||||
${attnCard(d)}
|
||||
<div class="acc-col">${feedCard(d)}</div>
|
||||
</div>
|
||||
${healthRow(d)}
|
||||
${resultsRow(d)}
|
||||
${quickRow()}
|
||||
<div class="acc-foot">
|
||||
<span>LearnSpace · командный центр администратора</span>
|
||||
<span>обновлено ${clockStr()}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function wire() {
|
||||
_root.querySelectorAll('[data-go]').forEach((el) => {
|
||||
el.addEventListener('click', () => go(el.getAttribute('data-go')));
|
||||
});
|
||||
_root.querySelectorAll('[data-tab]').forEach((el) => {
|
||||
el.addEventListener('click', () => { _tab = el.getAttribute('data-tab'); render(); });
|
||||
});
|
||||
const rb = _root.querySelector('[data-act="refresh"]');
|
||||
if (rb) rb.addEventListener('click', () => load());
|
||||
}
|
||||
|
||||
function renderSkeleton() {
|
||||
_root.innerHTML = `
|
||||
<div class="acc-shell">
|
||||
<div class="acc-skel-cards">
|
||||
<div class="acc-skel hero"></div><div class="acc-skel"></div>
|
||||
<div class="acc-skel"></div><div class="acc-skel"></div>
|
||||
</div>
|
||||
<div class="acc-skel-rows">
|
||||
<div class="acc-skel row"></div><div class="acc-skel row"></div>
|
||||
<div class="acc-skel row"></div><div class="acc-skel row"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startClock() {
|
||||
if (_clockTimer) return;
|
||||
_clockTimer = setInterval(() => {
|
||||
const c = document.getElementById('acc-clock');
|
||||
if (c) c.textContent = clockStr();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!_root) return;
|
||||
renderSkeleton();
|
||||
try {
|
||||
_data = await LS.adminGetOverview();
|
||||
render();
|
||||
startClock();
|
||||
} catch (err) {
|
||||
_root.innerHTML = `<div class="acc-shell"><div class="acc-attn-empty" style="padding:60px 20px">
|
||||
<b>Не удалось загрузить данные</b>
|
||||
<span>${e(err && err.message ? err.message : 'Ошибка сети')}</span>
|
||||
<button class="acc-btn" style="margin-top:14px" data-act="retry">Повторить</button>
|
||||
</div></div>`;
|
||||
const rb = _root.querySelector('[data-act="retry"]');
|
||||
if (rb) rb.addEventListener('click', () => load());
|
||||
}
|
||||
}
|
||||
|
||||
function mount(rootEl) {
|
||||
ensureAssets();
|
||||
_root = rootEl;
|
||||
load();
|
||||
}
|
||||
|
||||
window.DashAdminCenter = { mount, reload: load };
|
||||
|
||||
/* ════════════════════ SCOPED CSS ════════════════════ */
|
||||
const CSS = `
|
||||
#admin-command-center{
|
||||
--bg:#f3f4f7; --surface:#fff; --surface-2:#fafbfc; --surface-3:#f0f2f6;
|
||||
--border:#e7e9ef; --border-2:#dadde5;
|
||||
--tx:#181b22; --tx2:#525866; --tx3:#828997; --tx4:#aab0bb;
|
||||
--acc:#3558e0; --acc-600:#2a47bd; --acc-700:#233a9c; --acc-50:#eef1fe; --acc-100:#dee5fc;
|
||||
--acc-green:#16a34a; --acc-green-50:#e8f6ed; --acc-amber:#d97706; --acc-amber-50:#fdf1e2;
|
||||
--acc-rose:#e11d48; --acc-rose-50:#fdeaef; --acc-cyan:#0ea5b7; --acc-cyan-50:#e4f6f8;
|
||||
--acc-violet:#7c3aed; --acc-violet-50:#f1ecfe;
|
||||
--acc-sh-xs:0 1px 2px rgba(18,22,31,.04);
|
||||
--acc-sh-sm:0 1px 2px rgba(18,22,31,.05),0 1px 3px rgba(18,22,31,.05);
|
||||
--acc-sh:0 2px 4px -1px rgba(18,22,31,.05),0 8px 20px -6px rgba(18,22,31,.10);
|
||||
--acc-sh-accent:0 4px 14px -2px rgba(53,88,224,.40);
|
||||
--r-sm:9px; --r:12px; --r-lg:16px;
|
||||
--sans:'Hanken Grotesk',system-ui,-apple-system,sans-serif;
|
||||
--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;
|
||||
font-family:var(--sans); color:var(--tx); font-size:14px; line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
#admin-command-center *{ box-sizing:border-box; }
|
||||
#admin-command-center .acc-shell{ max-width:1380px; margin:0 auto; }
|
||||
#admin-command-center .acc-mono{ font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--tx2); }
|
||||
#admin-command-center .acc-ic{ width:18px; height:18px; flex:0 0 auto; display:inline-block;
|
||||
stroke:currentColor; fill:none; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
|
||||
#admin-command-center .acc-ic.sm{ width:15px; height:15px; stroke-width:1.8; }
|
||||
#admin-command-center .acc-ic.xs{ width:13px; height:13px; stroke-width:1.9; }
|
||||
|
||||
/* topbar */
|
||||
#admin-command-center .acc-topbar{ display:flex; align-items:center; gap:14px; margin-bottom:18px; flex-wrap:wrap; }
|
||||
#admin-command-center .acc-crumbs{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--tx3); }
|
||||
#admin-command-center .acc-crumbs .here{ color:var(--tx); font-weight:600; }
|
||||
#admin-command-center .acc-crumbs .acc-ic{ stroke:var(--tx4); }
|
||||
#admin-command-center .acc-clock{ font-family:var(--mono); font-size:12px; color:var(--tx3);
|
||||
padding-left:14px; border-left:1px solid var(--border); }
|
||||
#admin-command-center .acc-clock b{ color:var(--tx2); font-weight:600; }
|
||||
#admin-command-center .acc-tb-right{ margin-left:auto; display:flex; align-items:center; gap:8px; }
|
||||
#admin-command-center .acc-tb-icon{ width:34px; height:34px; display:grid; place-items:center;
|
||||
border-radius:var(--r-sm); border:1px solid var(--border); background:var(--surface);
|
||||
color:var(--tx2); box-shadow:var(--acc-sh-xs); transition:border-color .14s,color .14s; }
|
||||
#admin-command-center .acc-tb-icon:hover{ border-color:var(--border-2); color:var(--tx); }
|
||||
#admin-command-center .acc-btn{ display:inline-flex; align-items:center; gap:7px; height:34px; padding:0 14px;
|
||||
border-radius:var(--r-sm); font-size:13px; font-weight:600; border:1px solid var(--acc-600);
|
||||
background:linear-gradient(180deg,#4763e6,#3558e0); color:#fff;
|
||||
box-shadow:var(--acc-sh-accent),inset 0 1px 0 rgba(255,255,255,.2); transition:filter .14s,transform .12s; }
|
||||
#admin-command-center .acc-btn:hover{ filter:brightness(1.06); }
|
||||
#admin-command-center .acc-btn:active{ transform:translateY(1px); }
|
||||
#admin-command-center .acc-btn .acc-ic{ stroke:#fff; }
|
||||
|
||||
/* page head */
|
||||
#admin-command-center .acc-page-head{ display:flex; align-items:flex-end; justify-content:space-between; gap:24px; margin-bottom:20px; }
|
||||
#admin-command-center .acc-kicker{ display:inline-flex; align-items:center; gap:8px; font-family:var(--mono);
|
||||
font-size:11px; font-weight:500; letter-spacing:.03em; color:var(--acc); margin-bottom:9px; }
|
||||
#admin-command-center .acc-kicker .acc-ic{ stroke:var(--acc); }
|
||||
#admin-command-center .acc-kicker .muted{ color:var(--tx3); }
|
||||
#admin-command-center .acc-page-head h1{ font-size:30px; font-weight:800; letter-spacing:-.025em; line-height:1.05; margin:0; font-family:var(--sans); }
|
||||
#admin-command-center .acc-page-head h1 span{ color:var(--acc); }
|
||||
#admin-command-center .acc-sub{ margin:7px 0 0; font-size:14px; color:var(--tx2); max-width:60ch; }
|
||||
#admin-command-center .acc-sub b{ color:var(--tx); font-weight:700; }
|
||||
|
||||
/* pulse kpi */
|
||||
#admin-command-center .acc-pulse{ display:grid; grid-template-columns:1.4fr 1fr 1fr 1fr; gap:14px; margin-bottom:22px; }
|
||||
#admin-command-center .acc-kpi{ background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg);
|
||||
padding:16px 17px; box-shadow:var(--acc-sh-xs); position:relative; overflow:hidden; transition:transform .16s,box-shadow .16s,border-color .16s; }
|
||||
#admin-command-center .acc-kpi:hover{ transform:translateY(-2px); box-shadow:var(--acc-sh); border-color:var(--border-2); }
|
||||
#admin-command-center .acc-kpi-top{ display:flex; align-items:center; gap:9px; margin-bottom:11px; }
|
||||
#admin-command-center .acc-kpi-ic{ width:30px; height:30px; border-radius:8px; display:grid; place-items:center; flex:0 0 auto; }
|
||||
#admin-command-center .acc-kpi-ic.b{ background:var(--acc-50); } #admin-command-center .acc-kpi-ic.b .acc-ic{ stroke:var(--acc); }
|
||||
#admin-command-center .acc-kpi-ic.c{ background:var(--acc-cyan-50); } #admin-command-center .acc-kpi-ic.c .acc-ic{ stroke:var(--acc-cyan); }
|
||||
#admin-command-center .acc-kpi-ic.g{ background:var(--acc-green-50); } #admin-command-center .acc-kpi-ic.g .acc-ic{ stroke:var(--acc-green); }
|
||||
#admin-command-center .acc-kpi-ic.v{ background:var(--acc-violet-50); } #admin-command-center .acc-kpi-ic.v .acc-ic{ stroke:var(--acc-violet); }
|
||||
#admin-command-center .acc-lbl{ font-size:12px; font-weight:600; color:var(--tx3); }
|
||||
#admin-command-center .acc-live{ margin-left:auto; display:inline-flex; align-items:center; gap:5px;
|
||||
font-family:var(--mono); font-size:9px; font-weight:600; letter-spacing:.06em; color:var(--acc-green); }
|
||||
#admin-command-center .acc-dot{ width:6px; height:6px; border-radius:50%; background:var(--acc-green); position:relative; }
|
||||
#admin-command-center .acc-dot::after{ content:""; position:absolute; inset:-3px; border-radius:50%;
|
||||
border:1.4px solid var(--acc-green); animation:acc-ping 1.8s ease-out infinite; }
|
||||
@keyframes acc-ping{ 0%{ transform:scale(.5); opacity:.85 } 100%{ transform:scale(1.8); opacity:0 } }
|
||||
#admin-command-center .acc-num{ font-family:var(--mono); font-size:30px; font-weight:700; letter-spacing:-.03em;
|
||||
line-height:1; font-variant-numeric:tabular-nums; }
|
||||
#admin-command-center .acc-kpi.hero .acc-num{ font-size:38px; }
|
||||
#admin-command-center .acc-kpi-foot{ margin-top:11px; font-size:12px; color:var(--tx3); }
|
||||
#admin-command-center .acc-spark{ position:absolute; right:14px; bottom:14px; width:74px; height:26px; opacity:.85; pointer-events:none; }
|
||||
#admin-command-center .acc-kpi.hero .acc-spark{ width:120px; height:34px; }
|
||||
#admin-command-center .acc-line{ fill:none; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
|
||||
|
||||
/* main grid */
|
||||
#admin-command-center .acc-grid{ display:grid; grid-template-columns:minmax(0,1.55fr) minmax(0,1fr); gap:20px; align-items:start; margin-bottom:20px; }
|
||||
#admin-command-center .acc-col{ display:flex; flex-direction:column; gap:20px; }
|
||||
#admin-command-center .acc-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--acc-sh-xs); overflow:hidden; }
|
||||
#admin-command-center .acc-card-head{ display:flex; align-items:center; gap:10px; padding:14px 17px; border-bottom:1px solid var(--border); }
|
||||
#admin-command-center .acc-ttl-ic{ width:28px; height:28px; border-radius:8px; display:grid; place-items:center; flex:0 0 auto; background:var(--acc-50); }
|
||||
#admin-command-center .acc-ttl-ic .acc-ic{ stroke:var(--acc); }
|
||||
#admin-command-center .acc-card-head h2{ font-size:15px; font-weight:700; letter-spacing:-.01em; margin:0; font-family:var(--sans); }
|
||||
#admin-command-center .acc-count{ font-family:var(--mono); font-size:11px; font-weight:600; color:var(--tx2);
|
||||
background:var(--surface-3); border:1px solid var(--border); border-radius:99px; padding:2px 9px; }
|
||||
#admin-command-center .acc-more{ margin-left:auto; display:inline-flex; align-items:center; gap:5px;
|
||||
font-size:12.5px; font-weight:600; color:var(--tx3); cursor:pointer; transition:color .14s; }
|
||||
#admin-command-center .acc-more:hover{ color:var(--acc); }
|
||||
|
||||
/* attention */
|
||||
#admin-command-center .acc-attn{ border-color:#f1d9c4; box-shadow:0 0 0 1px rgba(217,119,6,.05),var(--acc-sh-sm); }
|
||||
#admin-command-center .acc-attn .acc-card-head{ background:linear-gradient(180deg,#fffaf3,var(--surface)); border-bottom-color:#f3e3d0; }
|
||||
#admin-command-center .acc-attn .acc-ttl-ic{ background:var(--acc-amber-50); }
|
||||
#admin-command-center .acc-attn .acc-ttl-ic .acc-ic{ stroke:var(--acc-amber); }
|
||||
#admin-command-center .acc-attn-tabs{ display:flex; gap:4px; padding:10px 12px 0; flex-wrap:wrap; }
|
||||
#admin-command-center .acc-attn-tab{ display:inline-flex; align-items:center; gap:7px; height:30px; padding:0 12px; border:none;
|
||||
background:none; border-radius:8px; font-size:12.5px; font-weight:600; color:var(--tx3); cursor:pointer; transition:background .14s,color .14s; font-family:var(--sans); }
|
||||
#admin-command-center .acc-attn-tab:hover{ background:var(--surface-3); color:var(--tx); }
|
||||
#admin-command-center .acc-attn-tab.on{ background:var(--tx); color:#fff; }
|
||||
#admin-command-center .acc-tag{ font-family:var(--mono); font-size:10px; font-weight:700; padding:1px 6px; border-radius:99px; background:var(--surface-3); color:var(--tx2); }
|
||||
#admin-command-center .acc-attn-tab.on .acc-tag{ background:rgba(255,255,255,.16); color:#fff; }
|
||||
#admin-command-center .acc-attn-list{ padding:8px; }
|
||||
#admin-command-center .acc-attn-row{ display:grid; grid-template-columns:40px minmax(0,1fr) auto; gap:12px; align-items:center;
|
||||
padding:11px 10px; border-radius:var(--r); transition:background .14s; }
|
||||
#admin-command-center .acc-attn-row+.acc-attn-row{ margin-top:1px; }
|
||||
#admin-command-center .acc-attn-row:hover{ background:var(--surface-3); }
|
||||
#admin-command-center .acc-sev{ width:40px; height:40px; border-radius:11px; display:grid; place-items:center; }
|
||||
#admin-command-center .acc-sev.rose{ background:var(--acc-rose-50); } #admin-command-center .acc-sev.rose .acc-ic{ stroke:var(--acc-rose); }
|
||||
#admin-command-center .acc-sev.amber{ background:var(--acc-amber-50); } #admin-command-center .acc-sev.amber .acc-ic{ stroke:var(--acc-amber); }
|
||||
#admin-command-center .acc-attn-main{ min-width:0; }
|
||||
#admin-command-center .acc-a-row1{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
#admin-command-center .acc-attn-main h4{ font-size:13.5px; font-weight:600; letter-spacing:-.01em; margin:0; font-family:var(--sans); }
|
||||
#admin-command-center .acc-kind{ font-family:var(--mono); font-size:9.5px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; padding:2px 7px; border-radius:6px; }
|
||||
#admin-command-center .acc-kind.rose{ background:var(--acc-rose-50); color:var(--acc-rose); }
|
||||
#admin-command-center .acc-kind.amber{ background:var(--acc-amber-50); color:var(--acc-amber); }
|
||||
#admin-command-center .acc-attn-meta{ font-size:11.5px; color:var(--tx3); margin-top:3px; }
|
||||
#admin-command-center .acc-attn-meta .acc-mono{ color:var(--tx2); }
|
||||
#admin-command-center .acc-attn-act{ display:inline-flex; align-items:center; gap:6px; height:30px; padding:0 12px; white-space:nowrap;
|
||||
border:1px solid var(--border); border-radius:var(--r-sm); background:var(--surface); font-size:12px; font-weight:600; color:var(--tx2);
|
||||
box-shadow:var(--acc-sh-xs); cursor:pointer; transition:border-color .14s,color .14s,background .14s; font-family:var(--sans); }
|
||||
#admin-command-center .acc-attn-act:hover{ border-color:var(--acc-600); color:var(--acc-700); background:var(--acc-50); }
|
||||
#admin-command-center .acc-attn-act.solid{ background:var(--tx); color:#fff; border-color:var(--tx); }
|
||||
#admin-command-center .acc-attn-act.solid:hover{ background:#000; }
|
||||
#admin-command-center .acc-attn-foot{ display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
padding:11px 17px; border-top:1px solid var(--border); background:var(--surface-2); font-size:12px; color:var(--tx3); }
|
||||
#admin-command-center .acc-attn-foot b{ color:var(--tx); font-weight:700; }
|
||||
#admin-command-center .acc-attn-empty{ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px;
|
||||
padding:46px 16px; text-align:center; color:var(--tx3); }
|
||||
#admin-command-center .acc-attn-empty .acc-ic{ stroke:var(--acc-green); width:30px; height:30px; margin-bottom:6px; }
|
||||
#admin-command-center .acc-attn-empty b{ color:var(--tx); font-size:14px; }
|
||||
#admin-command-center .acc-attn-empty span{ font-size:12.5px; }
|
||||
|
||||
/* feed */
|
||||
#admin-command-center .acc-feed{ padding:6px 8px 8px; max-height:430px; overflow-y:auto; }
|
||||
#admin-command-center .acc-feed-row{ display:grid; grid-template-columns:30px minmax(0,1fr) auto; gap:11px; align-items:center; padding:9px 10px; border-radius:var(--r); transition:background .14s; }
|
||||
#admin-command-center .acc-feed-row:hover{ background:var(--surface-3); }
|
||||
#admin-command-center .acc-feed-av{ width:30px; height:30px; border-radius:50%; display:grid; place-items:center; color:#fff; font-size:11px; font-weight:700; }
|
||||
#admin-command-center .acc-feed-main{ min-width:0; }
|
||||
#admin-command-center .acc-feed-main b{ font-size:13px; font-weight:600; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
#admin-command-center .acc-f-meta{ font-size:11px; color:var(--tx3); margin-top:1px; }
|
||||
#admin-command-center .acc-feed-right{ text-align:right; }
|
||||
#admin-command-center .acc-feed-pct{ font-family:var(--mono); font-size:14px; font-weight:700; line-height:1; }
|
||||
#admin-command-center .acc-feed-pct.hi{ color:var(--acc-green); } #admin-command-center .acc-feed-pct.mid{ color:var(--acc-amber); } #admin-command-center .acc-feed-pct.lo{ color:var(--acc-rose); }
|
||||
#admin-command-center .acc-feed-ago{ font-size:10px; color:var(--tx4); font-family:var(--mono); margin-top:2px; }
|
||||
|
||||
/* subject mini */
|
||||
#admin-command-center .acc-subj-mini{ padding:13px 17px; border-top:1px solid var(--border); background:var(--surface-2); }
|
||||
#admin-command-center .acc-sm-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:9px; font-size:11.5px; color:var(--tx3); font-weight:600; }
|
||||
#admin-command-center .acc-sm-head b{ font-family:var(--mono); color:var(--tx2); }
|
||||
#admin-command-center .acc-subj-track{ height:9px; border-radius:5px; overflow:hidden; display:flex; background:var(--border); margin-bottom:9px; }
|
||||
#admin-command-center .acc-seg{ height:100%; transition:width .6s cubic-bezier(.22,.72,.28,1); }
|
||||
#admin-command-center .acc-subj-legend{ display:flex; flex-wrap:wrap; gap:6px 13px; font-size:11px; color:var(--tx3); }
|
||||
#admin-command-center .acc-subj-legend span{ display:inline-flex; align-items:center; gap:5px; }
|
||||
#admin-command-center .acc-subj-legend b{ font-family:var(--mono); color:var(--tx2); font-weight:600; }
|
||||
#admin-command-center .acc-subj-dot{ width:7px; height:7px; border-radius:50%; }
|
||||
|
||||
/* section title */
|
||||
#admin-command-center .acc-sec-title{ display:flex; align-items:center; gap:9px; margin:4px 0 13px;
|
||||
font-family:var(--mono); font-size:11px; font-weight:500; letter-spacing:.1em; text-transform:uppercase; color:var(--tx3); }
|
||||
#admin-command-center .acc-ln{ flex:1; height:1px; background:linear-gradient(90deg,var(--border),transparent); }
|
||||
|
||||
/* health */
|
||||
#admin-command-center .acc-health{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:24px; }
|
||||
#admin-command-center .acc-hcard{ background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:15px 16px;
|
||||
box-shadow:var(--acc-sh-xs); transition:transform .16s,box-shadow .16s,border-color .16s; }
|
||||
#admin-command-center .acc-hcard:hover{ transform:translateY(-2px); box-shadow:var(--acc-sh); border-color:var(--border-2); }
|
||||
#admin-command-center .acc-hcard-top{ display:flex; align-items:center; gap:9px; margin-bottom:10px; }
|
||||
#admin-command-center .acc-hcard-ic{ width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:var(--surface-3); color:var(--tx2); }
|
||||
#admin-command-center .acc-hcard-ic .acc-ic{ stroke:currentColor; }
|
||||
#admin-command-center .acc-hn{ font-family:var(--mono); font-size:25px; font-weight:700; letter-spacing:-.02em; line-height:1; }
|
||||
|
||||
/* results */
|
||||
#admin-command-center .acc-results{ display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:24px; }
|
||||
#admin-command-center .acc-rtable{ width:100%; border-collapse:collapse; }
|
||||
#admin-command-center .acc-rtable th{ text-align:left; font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.06em;
|
||||
color:var(--tx3); font-weight:600; padding:9px 14px; border-bottom:1px solid var(--border); background:none; position:static; }
|
||||
#admin-command-center .acc-rtable th.r,#admin-command-center .acc-rtable td.r{ text-align:right; }
|
||||
#admin-command-center .acc-rtable td{ padding:10px 14px; font-size:13px; border-bottom:1px solid var(--border); }
|
||||
#admin-command-center .acc-rtable tr:last-child td{ border-bottom:none; }
|
||||
#admin-command-center .acc-rtable tbody tr{ transition:background .12s; }
|
||||
#admin-command-center .acc-rtable tbody tr:hover{ background:var(--surface-3); }
|
||||
#admin-command-center .acc-rt-user{ display:flex; align-items:center; gap:9px; font-weight:600; }
|
||||
#admin-command-center .acc-rt-av{ width:26px; height:26px; border-radius:50%; display:grid; place-items:center; color:#fff; font-size:10px; font-weight:700; flex:0 0 auto; }
|
||||
#admin-command-center .acc-rt-subj{ color:var(--tx2); font-size:12.5px; }
|
||||
#admin-command-center .acc-rt-score{ font-family:var(--mono); color:var(--tx3); font-size:12px; }
|
||||
#admin-command-center .acc-rt-pct{ font-family:var(--mono); font-weight:700; font-size:13px; }
|
||||
#admin-command-center .acc-rt-pct.hi{ color:var(--acc-green); } #admin-command-center .acc-rt-pct.mid{ color:var(--acc-amber); } #admin-command-center .acc-rt-pct.lo{ color:var(--acc-rose); }
|
||||
|
||||
/* quick */
|
||||
#admin-command-center .acc-quick{ display:grid; grid-template-columns:repeat(6,1fr); gap:12px; }
|
||||
#admin-command-center .acc-qbtn{ display:flex; flex-direction:column; gap:10px; padding:15px 14px; background:var(--surface);
|
||||
border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--acc-sh-xs); text-align:left; cursor:pointer;
|
||||
transition:transform .14s,box-shadow .14s,border-color .14s; font-family:var(--sans); }
|
||||
#admin-command-center .acc-qbtn:hover{ transform:translateY(-2px); box-shadow:var(--acc-sh); border-color:var(--acc-100); }
|
||||
#admin-command-center .acc-qbtn-ic{ width:34px; height:34px; border-radius:9px; display:grid; place-items:center; background:var(--acc-50); color:var(--acc); }
|
||||
#admin-command-center .acc-qbtn-ic .acc-ic{ stroke:currentColor; }
|
||||
#admin-command-center .acc-qbtn b{ font-size:13px; font-weight:700; letter-spacing:-.01em; }
|
||||
#admin-command-center .acc-qbtn span{ font-size:11px; color:var(--tx3); font-family:var(--mono); }
|
||||
|
||||
/* foot */
|
||||
#admin-command-center .acc-foot{ margin-top:26px; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px;
|
||||
font-size:12px; color:var(--tx4); font-family:var(--mono); }
|
||||
|
||||
/* skeleton */
|
||||
@keyframes acc-shimmer{ 0%{ background-position:-400px 0 } 100%{ background-position:400px 0 } }
|
||||
#admin-command-center .acc-skel{ border-radius:var(--r-lg);
|
||||
background:linear-gradient(90deg,var(--border) 25%,var(--surface) 50%,var(--border) 75%); background-size:400px 100%;
|
||||
animation:acc-shimmer 1.4s infinite linear; }
|
||||
#admin-command-center .acc-skel-cards{ display:grid; grid-template-columns:1.4fr 1fr 1fr 1fr; gap:14px; margin-bottom:22px; }
|
||||
#admin-command-center .acc-skel-cards .acc-skel{ height:118px; }
|
||||
#admin-command-center .acc-skel-rows{ display:flex; flex-direction:column; gap:12px; }
|
||||
#admin-command-center .acc-skel.row{ height:60px; }
|
||||
|
||||
/* responsive */
|
||||
@media (max-width:1160px){
|
||||
#admin-command-center .acc-grid{ grid-template-columns:1fr; }
|
||||
#admin-command-center .acc-pulse{ grid-template-columns:1fr 1fr; }
|
||||
#admin-command-center .acc-kpi.hero{ grid-column:span 2; }
|
||||
#admin-command-center .acc-quick{ grid-template-columns:repeat(3,1fr); }
|
||||
#admin-command-center .acc-health{ grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
@media (max-width:760px){
|
||||
#admin-command-center .acc-pulse{ grid-template-columns:1fr; }
|
||||
#admin-command-center .acc-kpi.hero{ grid-column:auto; }
|
||||
#admin-command-center .acc-results{ grid-template-columns:1fr; }
|
||||
#admin-command-center .acc-quick{ grid-template-columns:1fr 1fr; }
|
||||
#admin-command-center .acc-page-head h1{ font-size:25px; }
|
||||
#admin-command-center .acc-clock{ display:none; }
|
||||
}
|
||||
`;
|
||||
})();
|
||||
@@ -0,0 +1,212 @@
|
||||
'use strict';
|
||||
/*
|
||||
* flashcard-fab.js — глобальная плавающая кнопка «создать карточку».
|
||||
* Доступна на любой странице (учебник, лаборатория, симуляция…): ученик
|
||||
* мгновенно фиксирует то, что хочет запомнить, не уходя со страницы.
|
||||
*
|
||||
* Подключается лениво из sidebar.js (на всех страницах с шапкой).
|
||||
* Карточка уходит в POST /api/flashcards/quick (колода по выбору или
|
||||
* автоколода «Быстрые карточки»). Уважает фиче-флаг flashcards.
|
||||
*/
|
||||
(function () {
|
||||
if (typeof LS === 'undefined' || !LS.isLoggedIn || !LS.isLoggedIn()) return;
|
||||
if (document.getElementById('fc-fab')) return;
|
||||
|
||||
// Страницы, где FAB не нужен (полноэкранные / системные / сама страница карточек)
|
||||
const EXCLUDE = ['/login', '/403', '/404', '/500', '/classroom', '/guest-board', '/flashcards'];
|
||||
const path = location.pathname.replace(/\.html$/, '');
|
||||
if (EXCLUDE.some(p => path === p || path.startsWith(p + '/'))) return;
|
||||
|
||||
let _decks = null;
|
||||
let _open = false;
|
||||
|
||||
const esc = (s) => (LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
||||
|
||||
// Гейт по фиче-флагу
|
||||
(LS.loadFeatures ? LS.loadFeatures() : Promise.resolve({}))
|
||||
.then(feats => { if (!feats || feats.flashcards !== false) inject(); })
|
||||
.catch(() => inject());
|
||||
|
||||
function pageLabel() {
|
||||
let t = (document.title || '').split(/[—·|]/)[0].trim();
|
||||
return t && t.length < 48 ? t : '';
|
||||
}
|
||||
|
||||
function inject() {
|
||||
ensureStyles();
|
||||
|
||||
const fab = document.createElement('button');
|
||||
fab.id = 'fc-fab'; fab.type = 'button';
|
||||
fab.setAttribute('aria-label', 'Создать карточку');
|
||||
fab.title = 'Создать карточку (запомнить)';
|
||||
fab.innerHTML =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="5" width="14" height="15" rx="2.5"/><path d="M20 8v9"/><path d="M10 9.5v6M7 12.5h6"/></svg>';
|
||||
fab.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
|
||||
document.body.appendChild(fab);
|
||||
|
||||
const ctx = pageLabel();
|
||||
const pop = document.createElement('div');
|
||||
pop.id = 'fc-pop';
|
||||
pop.innerHTML =
|
||||
'<div class="fc-pop-head">' +
|
||||
'<span class="fc-pop-title">Новая карточка</span>' +
|
||||
(ctx ? '<span class="fc-pop-ctx" title="Текущая страница">' + esc(ctx) + '</span>' : '') +
|
||||
'<button class="fc-pop-x" type="button" aria-label="Закрыть"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' +
|
||||
'</div>' +
|
||||
'<label class="fc-lbl">Вопрос / лицевая сторона</label>' +
|
||||
'<textarea id="fc-front" class="fc-ta" rows="2" placeholder="Что спросить…" maxlength="5000"></textarea>' +
|
||||
'<label class="fc-lbl">Ответ / обратная сторона</label>' +
|
||||
'<textarea id="fc-back" class="fc-ta" rows="2" placeholder="Что вспомнить…" maxlength="5000"></textarea>' +
|
||||
'<label class="fc-lbl">Колода</label>' +
|
||||
'<select id="fc-deck" class="fc-sel"><option value="">Быстрые карточки</option></select>' +
|
||||
'<div class="fc-pop-foot">' +
|
||||
'<span class="fc-hint">Ctrl+Enter — сохранить</span>' +
|
||||
'<button id="fc-save" class="fc-save" type="button">Сохранить</button>' +
|
||||
'</div>';
|
||||
document.body.appendChild(pop);
|
||||
|
||||
pop.querySelector('.fc-pop-x').addEventListener('click', close);
|
||||
pop.querySelector('#fc-save').addEventListener('click', save);
|
||||
pop.addEventListener('click', (e) => e.stopPropagation());
|
||||
pop.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _open) close(); });
|
||||
document.addEventListener('click', () => { if (_open) close(); });
|
||||
}
|
||||
|
||||
function toggle() { _open ? close() : openPop(); }
|
||||
|
||||
async function openPop() {
|
||||
_open = true;
|
||||
document.getElementById('fc-pop').classList.add('fc-show');
|
||||
document.getElementById('fc-fab').classList.add('fc-fab-on');
|
||||
if (_decks === null) {
|
||||
try { const r = await LS.api('/api/flashcards/decks'); _decks = r.decks || []; }
|
||||
catch { _decks = []; }
|
||||
fillDecks();
|
||||
}
|
||||
setTimeout(() => document.getElementById('fc-front')?.focus(), 70);
|
||||
}
|
||||
|
||||
function close() {
|
||||
_open = false;
|
||||
document.getElementById('fc-pop')?.classList.remove('fc-show');
|
||||
document.getElementById('fc-fab')?.classList.remove('fc-fab-on');
|
||||
}
|
||||
|
||||
function fillDecks() {
|
||||
const sel = document.getElementById('fc-deck');
|
||||
if (!sel) return;
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">Быстрые карточки</option>' +
|
||||
(_decks || []).map(d => `<option value="${d.id}">${esc(d.title)}</option>`).join('');
|
||||
if (cur) sel.value = cur;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const front = (document.getElementById('fc-front').value || '').trim();
|
||||
const back = (document.getElementById('fc-back').value || '').trim();
|
||||
if (!front) { LS.toast('Заполни лицевую сторону (вопрос)', 'error'); document.getElementById('fc-front').focus(); return; }
|
||||
const deckId = document.getElementById('fc-deck').value;
|
||||
const btn = document.getElementById('fc-save');
|
||||
btn.disabled = true; btn.textContent = 'Сохраняю…';
|
||||
try {
|
||||
const r = await LS.api('/api/flashcards/quick', { method: 'POST', body: { front, back, deckId: deckId || undefined } });
|
||||
LS.toast('Карточка добавлена → «' + (r.deck_title || 'колода') + '»', 'success');
|
||||
document.getElementById('fc-front').value = '';
|
||||
document.getElementById('fc-back').value = '';
|
||||
// если создалась новая (быстрая) колода — перечитаем список при следующем открытии
|
||||
if (r.deck_id && _decks && !_decks.some(d => d.id === r.deck_id)) _decks = null;
|
||||
window.dispatchEvent(new CustomEvent('flashcard:added', { detail: r }));
|
||||
document.getElementById('fc-front').focus();
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + (e && e.message || 'не удалось сохранить'), 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById('fc-fab-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'fc-fab-style';
|
||||
s.textContent = `
|
||||
#fc-fab {
|
||||
position: fixed; right: 20px; bottom: 20px; z-index: 90;
|
||||
width: 54px; height: 54px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
||||
color: #fff; display: grid; place-items: center;
|
||||
box-shadow: 0 6px 22px rgba(155,93,229,0.45);
|
||||
transition: transform .18s cubic-bezier(.34,1.4,.64,1), box-shadow .18s;
|
||||
}
|
||||
#fc-fab svg { width: 24px; height: 24px; }
|
||||
#fc-fab:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 10px 30px rgba(155,93,229,0.55); }
|
||||
#fc-fab:active { transform: scale(0.96); }
|
||||
#fc-fab.fc-fab-on { transform: rotate(45deg); }
|
||||
#fc-fab::after {
|
||||
content: 'Запомнить'; position: absolute; right: 64px; top: 50%; transform: translateY(-50%);
|
||||
background: rgba(15,23,42,0.9); color: #fff; padding: 5px 11px; border-radius: 8px;
|
||||
font: 600 0.76rem 'Manrope', sans-serif; white-space: nowrap;
|
||||
opacity: 0; pointer-events: none; transition: opacity .15s;
|
||||
}
|
||||
#fc-fab:hover::after { opacity: 1; }
|
||||
#fc-fab.fc-fab-on::after { display: none; }
|
||||
|
||||
#fc-pop {
|
||||
position: fixed; right: 20px; bottom: 86px; z-index: 91;
|
||||
width: 320px; max-width: calc(100vw - 32px);
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.08); border-radius: 18px;
|
||||
box-shadow: 0 18px 50px rgba(15,23,42,0.22);
|
||||
padding: 16px; opacity: 0; transform: translateY(10px) scale(0.97);
|
||||
transform-origin: bottom right; pointer-events: none;
|
||||
transition: opacity .18s, transform .18s cubic-bezier(.34,1.3,.64,1);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
#fc-pop.fc-show { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.fc-pop-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.fc-pop-title { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800; color: #0F172A; }
|
||||
.fc-pop-ctx {
|
||||
font-size: 0.66rem; font-weight: 700; color: #7c3aed;
|
||||
background: rgba(155,93,229,0.1); padding: 3px 8px; border-radius: 99px;
|
||||
max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fc-pop-x { margin-left: auto; width: 28px; height: 28px; border: none; background: rgba(15,23,42,0.05);
|
||||
border-radius: 8px; cursor: pointer; color: #56687A; display: grid; place-items: center; }
|
||||
.fc-pop-x svg { width: 15px; height: 15px; }
|
||||
.fc-pop-x:hover { background: rgba(241,91,181,0.12); color: #db2777; }
|
||||
.fc-lbl { display: block; font-size: 0.68rem; font-weight: 700; color: #56687A;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin: 8px 0 4px; }
|
||||
.fc-ta, .fc-sel {
|
||||
width: 100%; box-sizing: border-box; padding: 9px 12px;
|
||||
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A;
|
||||
background: #fafbfc; resize: vertical; transition: border-color .15s;
|
||||
}
|
||||
.fc-ta:focus, .fc-sel:focus { outline: none; border-color: #9B5DE5; background: #fff; }
|
||||
.fc-sel { resize: none; cursor: pointer; }
|
||||
.fc-pop-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
|
||||
.fc-hint { font-size: 0.68rem; color: #94a3b8; }
|
||||
.fc-save {
|
||||
padding: 9px 20px; border: none; border-radius: 99px; cursor: pointer;
|
||||
background: linear-gradient(135deg, #06D6E0, #9B5DE5); color: #fff;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
|
||||
transition: filter .15s, transform .12s;
|
||||
}
|
||||
.fc-save:hover { filter: brightness(1.06); }
|
||||
.fc-save:active { transform: translateY(1px); }
|
||||
.fc-save:disabled { opacity: 0.6; cursor: default; }
|
||||
@media (max-width: 560px) {
|
||||
#fc-fab { width: 50px; height: 50px; right: 16px; bottom: 16px; }
|
||||
#fc-pop { right: 12px; left: 12px; width: auto; bottom: 78px; }
|
||||
#fc-fab::after { display: none; }
|
||||
}
|
||||
.app-layout.dark #fc-pop { background: #1A1D27; border-color: rgba(255,255,255,0.08); }
|
||||
.app-layout.dark .fc-pop-title { color: #E8ECF2; }
|
||||
.app-layout.dark .fc-ta, .app-layout.dark .fc-sel { background: #11141c; color: #E8ECF2; border-color: rgba(255,255,255,0.1); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LabPreviews — реальные SVG-превью симуляций (зеркало P_* из labs/lab-glue.js)
|
||||
* для карточки «Лаборатория дня» на дашборде. Тёмные тайлы 270×140.
|
||||
* Источник истины — lab-glue.js; здесь лёгкая копия, чтобы не грузить весь
|
||||
* движок лаборатории на дашборде.
|
||||
*/
|
||||
(function () {
|
||||
function _grid(fg='rgba(255,255,255,0.06)') {
|
||||
return `<g stroke="${fg}" stroke-width="1">
|
||||
<line x1="45" y1="0" x2="45" y2="140"/><line x1="90" y1="0" x2="90" y2="140"/>
|
||||
<line x1="135" y1="0" x2="135" y2="140"/><line x1="180" y1="0" x2="180" y2="140"/>
|
||||
<line x1="225" y1="0" x2="225" y2="140"/>
|
||||
<line x1="0" y1="35" x2="270" y2="35"/><line x1="0" y1="70" x2="270" y2="70"/>
|
||||
<line x1="0" y1="105" x2="270" y2="105"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function _svg(body) {
|
||||
return `<svg class="sim-preview" viewBox="0 0 270 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="270" height="140" fill="#0D0D1A"/>${body}</svg>`;
|
||||
}
|
||||
|
||||
const P_LENS = _svg(`${_grid('rgba(255,255,255,0.04)')}
|
||||
<line x1="10" y1="70" x2="260" y2="70" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<path d="M 135,20 Q 155,70 135,120 Q 115,70 135,20" fill="rgba(6,214,224,0.12)" stroke="#06D6E0" stroke-width="2"/>
|
||||
<line x1="30" y1="45" x2="135" y2="45" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<line x1="135" y1="45" x2="230" y2="90" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<line x1="30" y1="70" x2="230" y2="70" stroke="#06D6E0" stroke-width="1.5" stroke-dasharray="3,3" opacity="0.5"/>
|
||||
<line x1="30" y1="95" x2="135" y2="95" stroke="#F15BB5" stroke-width="1.8"/>
|
||||
<line x1="135" y1="95" x2="230" y2="55" stroke="#F15BB5" stroke-width="1.8"/>
|
||||
<circle cx="30" cy="70" r="5" fill="#9B5DE5" opacity="0.7"/>
|
||||
<line x1="30" y1="40" x2="30" y2="100" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/>`);
|
||||
|
||||
const P_CIRCUIT = _svg(`${_grid('rgba(255,255,255,0.04)')}
|
||||
<rect x="30" y="25" width="210" height="90" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="1.5" rx="4"/>
|
||||
<line x1="30" y1="70" x2="70" y2="70" stroke="#06D6E0" stroke-width="2"/>
|
||||
<rect x="70" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
|
||||
<text x="88" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₁</text>
|
||||
<line x1="106" y1="70" x2="130" y2="70" stroke="#06D6E0" stroke-width="2"/>
|
||||
<rect x="130" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
|
||||
<text x="148" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₂</text>
|
||||
<line x1="166" y1="70" x2="190" y2="70" stroke="#06D6E0" stroke-width="2"/>
|
||||
<rect x="190" y="56" width="18" height="28" fill="rgba(241,91,181,0.15)" stroke="#F15BB5" stroke-width="1.8" rx="3"/>
|
||||
<line x1="208" y1="70" x2="240" y2="70" stroke="#06D6E0" stroke-width="2"/>
|
||||
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">I = U/R</text>`);
|
||||
|
||||
const P_PENDULUM = _svg(`${_grid('rgba(255,255,255,0.04)')}
|
||||
<line x1="135" y1="15" x2="165" y2="95" stroke="rgba(255,255,255,0.5)" stroke-width="2"/>
|
||||
<circle cx="165" cy="100" r="12" fill="rgba(6,214,224,0.25)" stroke="#06D6E0" stroke-width="2"/>
|
||||
<line x1="135" y1="15" x2="95" y2="95" stroke="rgba(255,255,255,0.2)" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<circle cx="95" cy="100" r="12" fill="none" stroke="rgba(6,214,224,0.3)" stroke-width="1.5" stroke-dasharray="3,3"/>
|
||||
<path d="M 110,60 A 55,55 0 0 1 160,60" fill="none" stroke="rgba(6,214,224,0.4)" stroke-width="1.2" stroke-dasharray="3,3"/>
|
||||
<circle cx="135" cy="15" r="4" fill="rgba(255,255,255,0.5)"/>
|
||||
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">T = 2π√(l/g)</text>`);
|
||||
|
||||
const P_WAVES = _svg(`${_grid()}
|
||||
<line x1="0" y1="70" x2="270" y2="70" stroke="rgba(255,255,255,0.13)" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<path d="M 0,70 C 17,26 33,26 67,70 C 101,114 117,114 135,70 C 153,26 169,26 202,70 C 236,114 252,114 270,70"
|
||||
stroke="#9B5DE5" stroke-width="2" fill="none" opacity="0.7"/>
|
||||
<path d="M 0,70 C 22,18 44,18 90,70 C 136,122 158,122 180,70 C 202,18 224,18 270,70"
|
||||
stroke="#06D6E0" stroke-width="1.5" fill="none" opacity="0.5"/>
|
||||
<path d="M 0,70 C 12,10 28,8 50,40 C 72,72 88,118 112,85 C 136,52 155,18 180,50 C 205,82 240,108 270,70"
|
||||
stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/>
|
||||
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b</text>`);
|
||||
|
||||
const P_ISOPROCESS = _svg(`${_grid('rgba(255,255,255,0.04)')}
|
||||
<line x1="30" y1="10" x2="30" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
|
||||
<line x1="30" y1="125" x2="265" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
|
||||
<path d="M 50,20 Q 140,60 240,110" fill="none" stroke="#EF476F" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<path d="M 50,20 Q 130,80 230,118" fill="none" stroke="#FFD166" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<line x1="50" y1="20" x2="50" y2="118" stroke="#06D6E0" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<line x1="50" y1="20" x2="230" y2="20" stroke="#7BF5A4" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<path d="M 50,20 Q 120,55 220,108" fill="none" stroke="#EF476F" stroke-width="2.5"/>
|
||||
<circle cx="50" cy="20" r="5" fill="#9B5DE5"/>
|
||||
<circle cx="220" cy="108" r="5" fill="#EF476F"/>
|
||||
<text x="240" y="113" font-size="9" fill="#EF476F" font-family="Manrope,sans-serif">2</text>
|
||||
<text x="40" y="16" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif">1</text>
|
||||
<text x="255" y="128" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">V</text>
|
||||
<text x="18" y="12" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">P</text>`);
|
||||
|
||||
const P_3D = _svg(`${_grid('rgba(255,255,255,0.04)')}
|
||||
<polygon points="135,20 210,58 210,115 135,77" fill="rgba(155,93,229,0.15)" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<polygon points="135,20 60,58 60,115 135,77" fill="rgba(155,93,229,0.08)" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<polygon points="60,58 135,20 210,58 135,96" fill="rgba(155,93,229,0.22)" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<line x1="135" y1="77" x2="135" y2="96" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">V = a³</text>`);
|
||||
|
||||
window.LabPreviews = {
|
||||
opticsbench: P_LENS, circuit: P_CIRCUIT, pendulum: P_PENDULUM,
|
||||
waves: P_WAVES, isoprocess: P_ISOPROCESS, stereo: P_3D,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,318 @@
|
||||
'use strict';
|
||||
/*
|
||||
* PetSprite — единый источник модели питомца для /pet и /dashboard.
|
||||
* Канонические PET_PALETTES + shadeColor + renderPetSVG. pet.html и
|
||||
* dashboard.html используют window.PetSprite.render(...) — без дублей.
|
||||
*/
|
||||
(function () {
|
||||
const PET_PALETTES = {
|
||||
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
|
||||
red:'#F94144', green:'#38D95A', blue:'#4A90D9',
|
||||
};
|
||||
|
||||
function shadeColor(hex, pct) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = Math.min(255,Math.max(0,(n>>16)+pct));
|
||||
const g = Math.min(255,Math.max(0,((n>>8)&0xff)+pct));
|
||||
const b = Math.min(255,Math.max(0,(n&0xff)+pct));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0) {
|
||||
const col = PET_PALETTES[colorKey] || '#9B5DE5';
|
||||
const dark = shadeColor(col, -45);
|
||||
const light = shadeColor(col, 52);
|
||||
const uid = `pg${level}${mood[0]}${colorKey[0]}`;
|
||||
|
||||
const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z';
|
||||
const eyeY = 52, eyeX1 = 40, eyeX2 = 70;
|
||||
|
||||
/* ── Eyebrows ── */
|
||||
let eyebrows = '';
|
||||
if (mood !== 'sleeping') {
|
||||
if (mood === 'ecstatic') {
|
||||
eyebrows = `<path d="M33,46 C37,42 43,42 47,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M63,46 C67,42 73,42 77,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>`;
|
||||
} else if (mood === 'sad' || mood === 'hungry') {
|
||||
eyebrows = `<path d="M33,47 C37,51 43,51 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M63,47 C67,51 73,51 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
|
||||
} else {
|
||||
eyebrows = `<path d="M33,47 Q40,45 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M63,47 Q70,45 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Eyes ── */
|
||||
let eyeGroups = '', cheeks = '', extras = '';
|
||||
if (mood === 'sleeping') {
|
||||
eyeGroups = `<path d="M${eyeX1-8},${eyeY} Q${eyeX1},${eyeY-8} ${eyeX1+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M${eyeX2-8},${eyeY} Q${eyeX2},${eyeY-8} ${eyeX2+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
} else {
|
||||
const ry1 = mood === 'ecstatic' ? 13 : mood === 'happy' ? 12 : 10.5;
|
||||
const prx = mood === 'ecstatic' ? 7 : mood === 'happy' ? 6.5 : 5.5;
|
||||
const pry = mood === 'ecstatic' ? 8 : mood === 'happy' ? 7.5 : 6.5;
|
||||
const pOff = (mood === 'sad' || mood === 'hungry') ? 3 : 2;
|
||||
eyeGroups = `
|
||||
<g class="pet-eye-blink" style="transform-box:fill-box;transform-origin:center">
|
||||
<ellipse cx="${eyeX1}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
|
||||
<ellipse class="pet-pupil" cx="${eyeX1+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
|
||||
<circle cx="${eyeX1-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
|
||||
</g>
|
||||
<g class="pet-eye-blink2" style="transform-box:fill-box;transform-origin:center">
|
||||
<ellipse cx="${eyeX2}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
|
||||
<ellipse class="pet-pupil" cx="${eyeX2+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
|
||||
<circle cx="${eyeX2-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Nose ── */
|
||||
const nose = mood !== 'sleeping'
|
||||
? `<ellipse cx="55" cy="62" rx="3.5" ry="2.5" fill="${dark}" opacity=".45"/>` : '';
|
||||
|
||||
/* ── Mouth + cheeks ── */
|
||||
let mouth = '';
|
||||
if (mood === 'sleeping') {
|
||||
mouth = `<path d="M47,68 Q55,72 63,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
} else if (mood === 'ecstatic') {
|
||||
mouth = `<path d="M43,67 Q55,82 67,67" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
cheeks = `<ellipse cx="24" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>
|
||||
<ellipse cx="86" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>`;
|
||||
} else if (mood === 'happy') {
|
||||
mouth = `<path d="M44,68 Q55,80 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
cheeks = `<ellipse cx="25" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>
|
||||
<ellipse cx="85" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>`;
|
||||
} else if (mood === 'sad' || mood === 'hungry') {
|
||||
mouth = `<path d="M44,72 Q55,64 66,72" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
if (mood === 'hungry')
|
||||
extras = `<ellipse cx="78" cy="59" rx="2.5" ry="3.5" fill="rgba(130,195,255,.8)"/>`;
|
||||
} else {
|
||||
mouth = `<path d="M44,68 Q55,74 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
||||
}
|
||||
|
||||
/* ── Animated tail (all levels) ── */
|
||||
const tail = `<g>
|
||||
<path d="M68,91 C78,88 92,86 90,101 C88,110 76,110 72,103 C68,97 66,95 68,91 Z" fill="${col}" stroke="${dark}" stroke-width="1.2"/>
|
||||
<animateTransform attributeName="transform" type="rotate" values="-7 68 91; 7 68 91; -7 68 91" dur="0.75s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
|
||||
/* ── Ears (level 2+) ── */
|
||||
let ears = '';
|
||||
if (level >= 2) {
|
||||
ears = `<ellipse cx="32" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(-15 32 34)"/>
|
||||
<ellipse cx="32" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(-15 32 35)"/>
|
||||
<ellipse cx="78" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(15 78 34)"/>
|
||||
<ellipse cx="78" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(15 78 35)"/>`;
|
||||
}
|
||||
|
||||
/* ── Antennae (level 3+) ── */
|
||||
let antennae = '';
|
||||
if (level >= 3) {
|
||||
antennae = `<line x1="44" y1="27" x2="34" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="33" cy="9" r="5" fill="${col}"/>
|
||||
<circle cx="31" cy="7" r="2" fill="white" opacity=".7"/>
|
||||
<line x1="66" y1="27" x2="76" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="77" cy="9" r="5" fill="${col}"/>
|
||||
<circle cx="75" cy="7" r="2" fill="white" opacity=".7"/>`;
|
||||
}
|
||||
|
||||
/* ── Wings with flutter (level 4+) ── */
|
||||
let wings = '';
|
||||
if (level >= 4) {
|
||||
wings = `<g>
|
||||
<path d="M14,62 C2,46 2,30 16,38 C26,44 22,59 18,65 Z" fill="${light}" opacity=".65"/>
|
||||
<path d="M16,62 C6,50 8,38 18,42 C26,46 24,57 20,62 Z" fill="white" opacity=".15"/>
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 14 62; -10 14 62; 0 14 62" dur="0.45s" repeatCount="indefinite"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M96,62 C108,46 108,30 94,38 C84,44 88,59 92,65 Z" fill="${light}" opacity=".65"/>
|
||||
<path d="M94,62 C104,50 102,38 92,42 C84,46 86,57 90,62 Z" fill="white" opacity=".15"/>
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 96 62; 10 96 62; 0 96 62" dur="0.45s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Paws with fingers ── */
|
||||
const paws = `
|
||||
<ellipse cx="14" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(-25 14 76)"/>
|
||||
<circle cx="8" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="13" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="18" cy="83" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<ellipse cx="96" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(25 96 76)"/>
|
||||
<circle cx="102" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="97" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="92" cy="83" r="2.8" fill="${dark}" opacity=".45"/>`;
|
||||
|
||||
/* ── Accessories ── */
|
||||
let accSvg = '';
|
||||
if (accessories.includes('hat')) {
|
||||
accSvg += `<rect x="36" y="22" width="38" height="6" rx="3" fill="#2a2a2a"/>
|
||||
<rect x="42" y="6" width="26" height="17" rx="4" fill="#1a1a1a"/>
|
||||
<rect x="42" y="6" width="26" height="5" rx="2" fill="#333" opacity=".6"/>`;
|
||||
}
|
||||
if (accessories.includes('glasses')) {
|
||||
accSvg += `<circle cx="${eyeX1}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
<circle cx="${eyeX2}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
<line x1="54" y1="${eyeY}" x2="56" y2="${eyeY}" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
<line x1="19" y1="${eyeY-3}" x2="26" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>
|
||||
<line x1="91" y1="${eyeY-3}" x2="84" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>`;
|
||||
}
|
||||
if (accessories.includes('crown') || level >= 5) {
|
||||
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
|
||||
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
|
||||
<circle cx="41" cy="11" r="4" fill="#F94144"/>
|
||||
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
|
||||
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
|
||||
}
|
||||
if (accessories.includes('star') && level < 5) {
|
||||
accSvg += `<polygon points="98,18 100,24 106,24 101,28 103,34 98,30 93,34 95,28 90,24 96,24" fill="#F9C74F"/>`;
|
||||
}
|
||||
|
||||
/* ── Aura ring (level 4+) ── */
|
||||
let aura = '';
|
||||
if (level >= 4) {
|
||||
aura = `<g>
|
||||
<circle cx="55" cy="60" r="47" fill="none" stroke="${col}" stroke-width="2.5" stroke-dasharray="9 6">
|
||||
<animate attributeName="opacity" values=".35;.55;.35" dur="2.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="9s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── B3 Rainbow collar (streak ≥ 7) ── */
|
||||
let rainbowCollar = '';
|
||||
if (streak >= 7) {
|
||||
rainbowCollar = `<g style="animation:rbRot 3s linear infinite;transform-box:fill-box;transform-origin:55px 38px">
|
||||
<defs>
|
||||
<linearGradient id="${uid}rb" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#F94144"/>
|
||||
<stop offset="20%" stop-color="#F9C74F"/>
|
||||
<stop offset="40%" stop-color="#38D95A"/>
|
||||
<stop offset="60%" stop-color="#06D6E0"/>
|
||||
<stop offset="80%" stop-color="#9B5DE5"/>
|
||||
<stop offset="100%" stop-color="#F94144"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ellipse cx="55" cy="38" rx="22" ry="8" fill="none" stroke="url(#${uid}rb)" stroke-width="3.5" opacity=".88"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Orbital particles (level 5+) ── */
|
||||
let orbitals = '';
|
||||
if (level >= 5) {
|
||||
const oData = [
|
||||
{ start: 0, dur: 2.8, r: 46, size: 4, fill: col, op: .85 },
|
||||
{ start: 120, dur: 3.6, r: 43, size: 3, fill: light, op: .7 },
|
||||
{ start: 240, dur: 2.2, r: 48, size: 3.5, fill: 'white', op: .6 },
|
||||
];
|
||||
// Extra orbitals for level 6+
|
||||
if (level >= 6) {
|
||||
oData.push(
|
||||
{ start: 60, dur: 1.9, r: 51, size: 2.5, fill: light, op: .65 },
|
||||
{ start: 180, dur: 4.1, r: 40, size: 2, fill: col, op: .55 },
|
||||
{ start: 300, dur: 3.0, r: 54, size: 3, fill: 'white', op: .5 },
|
||||
);
|
||||
}
|
||||
// Even more for level 8
|
||||
if (level >= 8) {
|
||||
oData.push(
|
||||
{ start: 45, dur: 1.5, r: 56, size: 3.5, fill: '#FFD700', op: .9 },
|
||||
{ start: 225, dur: 2.4, r: 38, size: 2.5, fill: '#FFD700', op: .75 },
|
||||
);
|
||||
}
|
||||
orbitals = oData.map(o => `<g>
|
||||
<circle cx="55" cy="${60 - o.r}" r="${o.size}" fill="${o.fill}" opacity="${o.op}"/>
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
from="${o.start} 55 60" to="${o.start + 360} 55 60" dur="${o.dur}s" repeatCount="indefinite"/>
|
||||
</g>`).join('\n');
|
||||
}
|
||||
|
||||
/* ── Second aura ring (level 6+) ── */
|
||||
if (level >= 6) {
|
||||
aura += `<g>
|
||||
<circle cx="55" cy="60" r="53" fill="none" stroke="${col}" stroke-width="1.5" stroke-dasharray="5 9" opacity="0.4">
|
||||
<animate attributeName="opacity" values=".25;.45;.25" dur="3.2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<animateTransform attributeName="transform" type="rotate" from="360 55 60" to="0 55 60" dur="14s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Crystal halo (level 7+) ── */
|
||||
let halo = '';
|
||||
if (level >= 7) {
|
||||
halo = `<g>
|
||||
<ellipse cx="55" cy="18" rx="20" ry="5" fill="none" stroke="${col}" stroke-width="2.5" opacity=".75">
|
||||
<animate attributeName="opacity" values=".55;.85;.55" dur="2s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="55" cy="18" rx="14" ry="3" fill="none" stroke="white" stroke-width="1" opacity=".4"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Second wing pair (level 7+) ── */
|
||||
if (level >= 7) {
|
||||
wings += `<g>
|
||||
<path d="M18,55 C8,43 10,33 20,37 C27,40 25,50 22,55 Z" fill="${light}" opacity=".45"/>
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 18 55; -12 18 55; 0 18 55" dur="0.35s" repeatCount="indefinite"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M92,55 C102,43 100,33 90,37 C83,40 85,50 88,55 Z" fill="${light}" opacity=".45"/>
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 92 55; 12 92 55; 0 92 55" dur="0.35s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/* ── Third aura + body shimmer (level 8) ── */
|
||||
let shimmer = '';
|
||||
if (level >= 8) {
|
||||
aura += `<g>
|
||||
<circle cx="55" cy="60" r="58" fill="none" stroke="#FFD700" stroke-width="1" stroke-dasharray="3 12" opacity="0.35">
|
||||
<animate attributeName="opacity" values=".2;.5;.2" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="20s" repeatCount="indefinite"/>
|
||||
</g>`;
|
||||
shimmer = `<ellipse cx="55" cy="58" rx="30" ry="35" fill="url(#${uid}sh)" opacity="0.18"/>`;
|
||||
}
|
||||
|
||||
return `<svg viewBox="0 0 110 115" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="${uid}" cx="38%" cy="28%" r="70%">
|
||||
<stop offset="0%" stop-color="${light}"/>
|
||||
<stop offset="55%" stop-color="${col}"/>
|
||||
<stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="${uid}b" cx="50%" cy="55%" r="50%">
|
||||
<stop offset="0%" stop-color="white" stop-opacity="0.22"/>
|
||||
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="${uid}sh" cx="50%" cy="40%" r="60%">
|
||||
<stop offset="0%" stop-color="#FFD700" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#FFD700" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx="55" cy="111" rx="28" ry="4" fill="rgba(0,0,0,.18)"/>
|
||||
${aura}
|
||||
${halo}
|
||||
${tail}
|
||||
${wings}
|
||||
${ears}
|
||||
${antennae}
|
||||
${paws}
|
||||
<path d="${bodyPath}" fill="url(#${uid})"/>
|
||||
${shimmer}
|
||||
<ellipse cx="55" cy="70" rx="18" ry="12" fill="url(#${uid}b)"/>
|
||||
${cheeks}${eyebrows}${eyeGroups}${nose}${mouth}${extras}
|
||||
${rainbowCollar}
|
||||
${accSvg}
|
||||
${orbitals}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
var MOOD_RU = {
|
||||
ecstatic: 'в восторге', happy: 'в духе', neutral: 'бодр',
|
||||
sad: 'скучает', hungry: 'голоден', sleeping: 'спит',
|
||||
};
|
||||
|
||||
window.PetSprite = {
|
||||
PALETTES: PET_PALETTES,
|
||||
shade: shadeColor,
|
||||
render: renderPetSVG,
|
||||
moodLabel: function (m) { return MOOD_RU[m] || 'бодр'; },
|
||||
};
|
||||
})();
|
||||
@@ -835,6 +835,21 @@
|
||||
.av-or::before, .av-or::after {
|
||||
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06);
|
||||
}
|
||||
/* ── Рейтинг (leaderboard) ── */
|
||||
.lb-head { display: flex; justify-content: flex-end; margin: 4px 0 12px; }
|
||||
.lb-tabs { display: inline-flex; gap: 4px; background: var(--bg); border: 1.5px solid var(--border); border-radius: 10px; padding: 3px; }
|
||||
.lb-tab { padding: 5px 14px; border: none; background: none; border-radius: 7px; font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all 0.15s; }
|
||||
.lb-tab:hover:not(.active) { color: var(--text-2); }
|
||||
.lb-tab.active { background: var(--violet); color: #fff; }
|
||||
.lb-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.lb-row { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-radius: 12px; background: var(--bg); border: 1.5px solid var(--border); transition: all 0.15s; }
|
||||
.lb-row:hover { border-color: rgba(155,93,229,0.25); }
|
||||
.lb-row-me { background: rgba(155,93,229,0.08); border-color: rgba(155,93,229,0.3); }
|
||||
.lb-rank { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 0.95rem; width: 26px; text-align: center; flex-shrink: 0; }
|
||||
.lb-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--violet); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; overflow: hidden; flex-shrink: 0; }
|
||||
.lb-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.lb-name { flex: 1; font-weight: 600; font-size: 0.9rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lb-xp { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.85rem; color: var(--violet); flex-shrink: 0; }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
</head>
|
||||
@@ -1017,6 +1032,24 @@
|
||||
<div class="frames-grid" id="frames-grid"></div>
|
||||
</div>
|
||||
<div class="ach-grid" id="ach-grid"></div>
|
||||
|
||||
<!-- Рейтинг (перенесён с дашборда) -->
|
||||
<div class="p-card" id="lb-section" style="display:none;margin-top:14px">
|
||||
<div class="p-card-header">
|
||||
<div class="p-card-icon"><i data-lucide="trophy" style="width:15px;height:15px"></i></div>
|
||||
<div>
|
||||
<div class="p-card-title">Рейтинг</div>
|
||||
<div class="p-card-sub">Топ учеников по опыту</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lb-head">
|
||||
<div class="lb-tabs">
|
||||
<button class="lb-tab active" onclick="setLbPeriod('week',this)">Неделя</button>
|
||||
<button class="lb-tab" onclick="setLbPeriod('all',this)">Всё время</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lb-list" id="lb-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Магазин -->
|
||||
@@ -1981,7 +2014,47 @@
|
||||
loadFrames();
|
||||
loadBookmarks();
|
||||
|
||||
/* ── Рейтинг (leaderboard) ── */
|
||||
const _lbEsc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
let _lbPeriod = 'week';
|
||||
function setLbPeriod(p, btn) {
|
||||
_lbPeriod = p;
|
||||
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
loadLeaderboard();
|
||||
}
|
||||
window.setLbPeriod = setLbPeriod;
|
||||
async function loadLeaderboard() {
|
||||
const section = document.getElementById('lb-section');
|
||||
const list = document.getElementById('lb-list');
|
||||
if (!section || !list) return;
|
||||
section.style.display = ''; // карточка видна всегда
|
||||
try {
|
||||
const data = await LS.api('/api/gamification/leaderboard?period=' + encodeURIComponent(_lbPeriod));
|
||||
if (!data || !data.length) {
|
||||
list.innerHTML = '<div style="text-align:center;color:var(--text-3);font-size:0.82rem;padding:18px 0">Пока нет данных рейтинга. Проходи тесты и набирай XP!</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.map((u, i) => {
|
||||
const rank = i + 1;
|
||||
const medal = rank === 1 ? '#FFD700' : rank === 2 ? '#C0C0C0' : rank === 3 ? '#CD7F32' : 'transparent';
|
||||
const meClass = u.is_me ? ' lb-row-me' : '';
|
||||
return `
|
||||
<div class="lb-row${meClass}">
|
||||
<div class="lb-rank" style="color:${medal !== 'transparent' ? medal : 'var(--text-3)'}">${rank}</div>
|
||||
<div class="lb-avatar">${u.avatar ? `<img src="${_lbEsc(u.avatar)}">` : _lbEsc((u.name || '?')[0])}</div>
|
||||
<div class="lb-name">${_lbEsc(u.name || 'Ученик')}</div>
|
||||
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
section.style.display = '';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch { section.style.display = 'none'; }
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
loadLeaderboard();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
/* ── Настройки (prefs tab) ── */
|
||||
|
||||
+508
-12
@@ -375,6 +375,20 @@
|
||||
.tg-section.search-hidden { display: none; }
|
||||
.tg-chapter.search-hidden { display: none; }
|
||||
|
||||
/* Admin guide */
|
||||
.tg-admin-divider { display:flex; align-items:center; gap:8px; margin:20px 0 8px; font-family:'Unbounded',sans-serif; font-size:0.58rem; font-weight:800; letter-spacing:0.09em; text-transform:uppercase; color:#c0306a; }
|
||||
.tg-admin-divider::before,.tg-admin-divider::after { content:''; flex:1; height:1px; background:rgba(241,91,181,0.2); }
|
||||
.tg-nav-chapter.admin .tg-nav-ch-btn { color:#c0306a; }
|
||||
.tg-nav-chapter.admin .tg-nav-ch-btn .tg-nav-ch-icon svg { stroke:#c0306a; }
|
||||
.tg-nav-chapter.admin .tg-nav-ch-btn:hover,.tg-nav-chapter.admin .tg-nav-ch-btn.active { background:rgba(241,91,181,0.07); }
|
||||
.tg-chapter.admin-chapter .tg-chapter-header { background:linear-gradient(135deg,rgba(241,91,181,0.04),rgba(155,93,229,0.04)); border-radius:16px; margin-bottom:4px; }
|
||||
.tg-chapter.admin-chapter .tg-chapter-icon svg { stroke:#c0306a; }
|
||||
.tg-chapter.admin-chapter .tg-chapter-num { color:#c0306a; }
|
||||
.tg-chapter.admin-chapter .tg-chapter-try { background:rgba(241,91,181,0.1); color:#c0306a; border:1.5px solid rgba(241,91,181,0.25); }
|
||||
.tg-chapter.admin-chapter .tg-chapter-try:hover { background:#c0306a; color:#fff; }
|
||||
.tg-chapter-admin-header { display:inline-flex; align-items:center; gap:7px; padding:5px 13px; border-radius:99px; background:rgba(241,91,181,0.1); color:#c0306a; font-size:0.74rem; font-weight:700; margin-bottom:18px; }
|
||||
.tg-chapter-admin-header svg { width:13px; height:13px; stroke:currentColor; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 1024px) {
|
||||
.tg-layout { padding: 0 16px 60px; gap: 0; }
|
||||
@@ -409,6 +423,10 @@
|
||||
<input type="text" id="tg-search" placeholder="Поиск по руководству..." />
|
||||
</div>
|
||||
<div id="tg-nav-chapters"></div>
|
||||
<div id="tg-nav-admin" style="display:none">
|
||||
<div class="tg-admin-divider"><i data-lucide="shield" style="width:12px;height:12px;stroke:#c0306a;vertical-align:-2px"></i> Администратор</div>
|
||||
<div id="tg-nav-admin-chapters"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ══ CONTENT ══ -->
|
||||
@@ -1190,7 +1208,7 @@
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Иконка корзины</b> — убрать ученика из списка. Уже созданные задания НЕ удаляются — ученик продолжит их видеть.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Счётчик «Заданий» показывает, сколько вы лично выдали этому ученику.</div></div>
|
||||
</div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Теперь вы знаете все 13 разделов руководства. Удачных уроков и репетиторских часов!</div></div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="arrow-right-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Продолжайте изучение</div>Следующие главы: Лаборатория (14), Биохимия (15), Геймификация (16), Доступ к контенту (17).</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
@@ -1198,6 +1216,216 @@
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Экзамен 9 класс</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-14')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Лаборатория</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CHAPTER 14 — LAB ═══ -->
|
||||
<div class="tg-chapter" id="ch-14">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="flask-conical"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава 14</div>
|
||||
<div class="tg-chapter-title">Виртуальная лаборатория</div>
|
||||
</div>
|
||||
<a href="/lab" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Лаборатория</a>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-14-1">
|
||||
<div class="tg-section-title">14.1 Что это и зачем</div>
|
||||
<p>Виртуальная лаборатория — 40 интерактивных симуляций по физике, химии, математике и биологии. Всё работает прямо в браузере, никаких установок не нужно.</p>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="atom"></i></div><div><div class="tg-tool-name">Физика (14)</div><div class="tg-tool-desc">Маятник, волны, оптика, электричество, газы…</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="flask-conical"></i></div><div><div class="tg-tool-name">Химия (14)</div><div class="tg-tool-desc">Орбитали, кристаллы, реакции, периодическая таблица…</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="calculator"></i></div><div><div class="tg-tool-name">Математика (9)</div><div class="tg-tool-desc">Графики, планиметрия, стереометрия 3D, вероятность…</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="leaf"></i></div><div><div class="tg-tool-name">Биология (2)</div><div class="tg-tool-desc">Деление клетки, фотосинтез</div></div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="link"></i></div><div class="tg-box-body"><div class="tg-box-label">Deep-link на конкретную симуляцию</div>Откройте нужную симуляцию и скопируйте адрес — в нём уже будет параметр <b>?sim=название</b>. Вставьте ссылку в задание или чат урока, и ученик попадёт прямо в неё.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-14-2">
|
||||
<div class="tg-section-title">14.2 Связь с учебниками</div>
|
||||
<p>В учебниках рядом с нужными параграфами появляется кнопка <b>«В лабораторию»</b> с числом связанных симуляций. Нажмите — откроется лаборатория с уже выбранной симуляцией по теме.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте учебник → нужный параграф.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Нажмите кнопку с иконкой колбы в правом углу параграфа.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Браузер откроет лабораторию и автоматически запустит нужную симуляцию.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-14-3">
|
||||
<div class="tg-section-title">14.3 Стереометрия 3D</div>
|
||||
<p>Симуляция <b>«Стереометрия»</b> — интерактивные объёмные тела: куб, пирамида, призма, конус, цилиндр, сфера, правильные многогранники и усечённые фигуры.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Вращение</b> — зажмите кнопку мыши и тяните. Фигура крутится в 3D.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Производные точки</b> — середины рёбер, центры граней, засечки.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Deep-link на фигуру</b> — например, <code>/lab?sim=stereo:cube</code> откроет сразу куб.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="monitor"></i></div><div class="tg-box-body"><div class="tg-box-label">На онлайн-уроке</div>Откройте симуляцию в соседней вкладке и включите демонстрацию экрана — ученики увидят 3D-вращение в реальном времени. Или используйте режим аннотации прямо поверх симуляции на доске.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-13')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Мои ученики</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-15')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Биохимия</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CHAPTER 15 — BIOCHEM ═══ -->
|
||||
<div class="tg-chapter" id="ch-15">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="atom"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава 15</div>
|
||||
<div class="tg-chapter-title">Биохимия</div>
|
||||
</div>
|
||||
<a href="/biochem" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Биохимия</a>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-15-1">
|
||||
<div class="tg-section-title">15.1 Молекулярный редактор</div>
|
||||
<p>Страница <b>/biochem</b> — интерактивный редактор молекул. Собирайте молекулы из атомов и связей, платформа мгновенно вычисляет свойства.</p>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="circle"></i></div><div><div class="tg-tool-name">Атомы</div><div class="tg-tool-desc">C, H, O, N, S, P, галогены, металлы</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="git-merge"></i></div><div><div class="tg-tool-name">Связи</div><div class="tg-tool-desc">Одинарные, двойные, тройные</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="box"></i></div><div><div class="tg-tool-name">3D VSEPR</div><div class="tg-tool-desc">Настоящая 3D-геометрия по теории ОЭПВО</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="zap"></i></div><div><div class="tg-tool-name">Заряды δ±</div><div class="tg-tool-desc">Тепловая карта, стрелка диполя</div></div></div>
|
||||
</div>
|
||||
<p>В панели свойств автоматически отображаются: <b>молярная масса</b>, <b>DBE</b> (степень ненасыщенности), <b>гибридизация</b>, <b>форма молекулы</b>, <b>дипольный момент</b>, <b>полярность</b>, <b>функциональные группы</b>.</p>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="alert-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Валентность в реальном времени</div>Если превысить валентность атома — появится подсказка: «Углерод (C): занято 5 связей, максимум 4 — убери 1». Сохранение заблокируется до исправления.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-15-2">
|
||||
<div class="tg-section-title">15.2 Библиотека и свойства</div>
|
||||
<p><b>/biochem-library</b> — 105+ молекул с карточками и 2D/3D-превью. Можно сравнивать несколько молекул и экспортировать таблицу в CSV.</p>
|
||||
<p><b>/biochem-properties</b> — сравнение молекул: столбчатый график молярных масс, массовые доли элементов.</p>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="download"></i></div><div class="tg-box-body"><div class="tg-box-label">SMILES и экспорт</div>В редакторе можно ввести SMILES-строку (например, <code>CC(=O)O</code> — уксусная кислота), молекула построится автоматически. Готовую молекулу можно экспортировать как PNG или JSON.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-15-3">
|
||||
<div class="tg-section-title">15.3 Реакции и метаболические пути</div>
|
||||
<p><b>/biochem-reactions</b> — 27 реакций с автоматической балансировкой (показывает коэффициенты) и энергодиаграммой (экзо-/эндотермические с ΔH).</p>
|
||||
<p><b>/biochem-pathways</b> — метаболические пути (гликолиз, цикл Кребса и др.) с пошаговым Learn-режимом. За прохождение начисляется XP.</p>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-14')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Лаборатория</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-16')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Геймификация</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CHAPTER 16 — GAMIFICATION ═══ -->
|
||||
<div class="tg-chapter" id="ch-16">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="zap"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава 16</div>
|
||||
<div class="tg-chapter-title">Геймификация и питомец</div>
|
||||
</div>
|
||||
<a href="/pet" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Питомец</a>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-16-1">
|
||||
<div class="tg-section-title">16.1 XP, уровни и достижения</div>
|
||||
<p>Каждое действие ученика приносит <b>опыт (XP)</b>: тесты, лаборатория, чтение учебников, ежедневная активность, ответы на уроке.</p>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="trending-up"></i></div><div><div class="tg-tool-name">8 уровней</div><div class="tg-tool-desc">Новичок → Легенда, у каждого своя модель питомца</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="trophy"></i></div><div><div class="tg-tool-name">38+ достижений</div><div class="tg-tool-desc">Стрики, лаборатория, тесты, биохимия, топ-10</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="flame"></i></div><div><div class="tg-tool-name">Стрики</div><div class="tg-tool-desc">Серия дней подряд — бонусный XP</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="target"></i></div><div><div class="tg-tool-name">Ежедневная цель</div><div class="tg-tool-desc">Лёгкая / Средняя / Тяжёлая</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-16-2">
|
||||
<div class="tg-section-title">16.2 Виртуальный питомец</div>
|
||||
<p>У каждого ученика есть личный питомец — существо, которое растёт вместе с прогрессом. Ухаживайте за ним: кормите, гладьте.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Имя</b> — ученик выбирает сам при первом посещении /pet.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Цвет</b> — 6 вариантов (фиолетовый, голубой, золотой, красный, зелёный, синий).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Эволюция</b> — с ростом XP появляются уши (ур. 2), антенны (3), крылья (4), корона (5+).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Настроение</b> — зависит от стрика и частоты визитов: бодр, в духе, скучает, голоден.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="star"></i></div><div class="tg-box-body"><div class="tg-box-label">Радужный ошейник</div>При стрике 7+ дней подряд питомец получает анимированный радужный ошейник. Отличный мотиватор для регулярной работы!</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-15')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Биохимия</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-17')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Доступ к контенту</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CHAPTER 17 — ACCESS ═══ -->
|
||||
<div class="tg-chapter" id="ch-17">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="shield-check"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава 17</div>
|
||||
<div class="tg-chapter-title">Доступ к контенту</div>
|
||||
</div>
|
||||
<a href="/admin" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Админ-панель</a>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-17-1">
|
||||
<div class="tg-section-title">17.1 Открытие учебников и экзаменов классу</div>
|
||||
<p>По умолчанию ученики видят только те учебники и экзамены, к которым им открыт доступ. Это настраивается через <b>allowlist</b> — белый список.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте <b>Админ-панель → вкладка «Доступ к контенту»</b>.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Выберите учебник или экзамен из списка.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Добавьте класс — все ученики класса получат доступ. Или выберите конкретного ученика — доступ только у него.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="users"></i></div><div class="tg-box-body"><div class="tg-box-label">Приоритеты</div>Доступ ученика выше доступа класса: если классу учебник закрыт, но конкретному ученику открыт — он его увидит.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-17-2">
|
||||
<div class="tg-section-title">17.2 Feature Flags — включение модулей</div>
|
||||
<p>В <b>Админ-панели → «Настройки»</b> можно включать и отключать целые модули платформы:</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Биохимия</b> — полностью скрыть модуль для учеников.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Учебники</b> — отключить раздел учебников.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Флэшкарты, Live-квиз</b> — включить/выключить по необходимости.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Экзаменационные тесты</b> — модуль с 80 вариантами по математике 9 класса.</div></div>
|
||||
</div>
|
||||
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Только для администратора</div>Изменение feature flags и доступа к контенту доступно только пользователям с ролью <b>admin</b>. Учителя видят только то, к чему у их класса есть доступ.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-17-3">
|
||||
<div class="tg-section-title">17.3 System Health</div>
|
||||
<p>Администраторам доступна вкладка <b>«System Health»</b> в панели с метриками сервера в реальном времени:</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>CPU и RAM</b> — текущая нагрузка процессора и память.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Event loop lag</b> — задержка обработки запросов.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>HTTP-статистика</b> — количество запросов, самые медленные и частые роуты.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Тренды</b> — canvas-графики нагрузки за последние минуты.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Журнал ошибок</b> — последние ошибки сервера с трейсом.</div></div>
|
||||
</div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Теперь вы знаете все 17 разделов руководства. Удачных уроков!</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-16')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Геймификация</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
|
||||
@@ -1205,6 +1433,245 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ ADMIN GUIDE (только для роли admin, скрыт по умолчанию) ═══ -->
|
||||
<div id="tg-admin-content" style="display:none">
|
||||
|
||||
<!-- A1 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a1">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="layout-dashboard"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A1</div>
|
||||
<div class="tg-chapter-title">Командный центр</div>
|
||||
</div>
|
||||
<a href="/dashboard" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Дашборд</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a1-1">
|
||||
<div class="tg-section-title">A1.1 Дашборд администратора</div>
|
||||
<p>Администратор попадает на <b>командный центр</b> — специальный дашборд вместо ученического. Показывает состояние платформы за последние 24 часа.</p>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="play-circle"></i></div><div><div class="tg-tool-name">Сессий запущено</div><div class="tg-tool-desc">Со спарклайном за 7 дней</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="activity"></i></div><div><div class="tg-tool-name">Активных пользователей</div><div class="tg-tool-desc">За 24 часа</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="user-plus"></i></div><div><div class="tg-tool-name">Новые регистрации</div><div class="tg-tool-desc">За 24 часа</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="check-circle"></i></div><div><div class="tg-tool-name">Завершаемость</div><div class="tg-tool-desc">Доля незаброшенных сессий</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a1-2">
|
||||
<div class="tg-section-title">A1.2 Очередь триажа «Требует внимания»</div>
|
||||
<p>Центральный блок дашборда — события, требующие действия. Три вкладки:</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Блокировки</b> — заблокированные за неделю. Кнопка «Открыть» → карточка пользователя.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Зависшие</b> — сессии, не завершившиеся более 1 часа. Кнопка «Сессия» → к ней.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Брошенные</b> — всплеск прерванных сессий за 24ч (сигнал проблемы).</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a1-3">
|
||||
<div class="tg-section-title">A1.3 Лента завершений и статистика</div>
|
||||
<p><b>Лента завершений</b> — поток последних завершённых сессий с % и распределением по предметам.</p>
|
||||
<p><b>Результаты дня</b>: Топ-5 (лучшие) и «Нужна помощь» (<50%) — быстрый способ выявить учеников, которым сложно.</p>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-a2')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Управление пользователями</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A2 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a2">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="users"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A2</div>
|
||||
<div class="tg-chapter-title">Управление пользователями</div>
|
||||
</div>
|
||||
<a href="/admin#users" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Пользователи</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a2-1">
|
||||
<div class="tg-section-title">A2.1 Список и фильтры</div>
|
||||
<p>Вкладка <b>«Пользователи»</b> — все зарегистрированные: имя, email, роль, регистрация, последний вход, количество тестов, средний балл. Фильтры: по роли, поиск по имени/email.</p>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a2-2">
|
||||
<div class="tg-section-title">A2.2 Карточка пользователя</div>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Смена роли</b> — student / teacher / admin / free_student. Инвалидирует токен.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Блокировка / разблокировка</b> — заблокированный не может войти.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>История сессий</b> — все тестовые сессии с результатами.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Удаление</b> — полное удаление пользователя и всех его данных.</div></div>
|
||||
</div>
|
||||
<div class="tg-warning"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Нельзя изменить свою роль</div>Защита от случайного лишения прав.</div></div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a2-3">
|
||||
<div class="tg-section-title">A2.3 Глобальный поиск (Ctrl+K)</div>
|
||||
<p>В любом разделе нажмите <b>Ctrl+K</b> — command palette: поиск пользователей, тестов, классов. Результаты кликабельны.</p>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a1')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Командный центр</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-a3')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Контент и доступ</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A3 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a3">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="book-lock"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A3</div>
|
||||
<div class="tg-chapter-title">Контент и доступ к учебникам</div>
|
||||
</div>
|
||||
<a href="/admin#access" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Доступ</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a3-1">
|
||||
<div class="tg-section-title">A3.1 Allowlist — открытие учебников</div>
|
||||
<p>Вкладка <b>«Доступ к контенту»</b>. Ученики видят только учебники/экзамены, которые администратор явно открыл. Выберите учебник → добавьте класс или конкретного ученика.</p>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="shield-check"></i></div><div class="tg-box-body"><div class="tg-box-label">Приоритеты</div>Доступ ученика выше доступа класса. Администраторы и учителя видят весь контент всегда.</div></div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a3-2">
|
||||
<div class="tg-section-title">A3.2 Управление симуляциями</div>
|
||||
<p>Вкладка <b>«Симуляции»</b> — каталог 40 симуляций: включить/выключить, пометить как рекомендованную (featured), привязать к параграфам учебников (кнопка «В лабораторию»).</p>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a3-3">
|
||||
<div class="tg-section-title">A3.3 Feature Flags</div>
|
||||
<p>Включение/отключение модулей платформы без перезапуска сервера: биохимия, учебники, флэшкарты, доска, live-квиз, экзамен-9, симуляции.</p>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a2')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Пользователи</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-a4')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Геймификация (admin)</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a4">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="zap"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A4</div>
|
||||
<div class="tg-chapter-title">Геймификация — панель администратора</div>
|
||||
</div>
|
||||
<a href="/admin#gam" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Геймификация</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a4-1">
|
||||
<div class="tg-section-title">A4.1 Статистика</div>
|
||||
<p>Суммарный XP и монеты, средний уровень, достижений выдано, Топ-10 по XP, последние начисления XP с читаемыми подписями.</p>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a4-2">
|
||||
<div class="tg-section-title">A4.2 Начисление XP и монет</div>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Выберите пользователя из списка. Фильтр по имени для быстрого поиска.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте XP через пресеты (0/+10/+25/+50/+100/+250). <b>0 = не начисляется.</b></div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Задайте монеты (0/+10/+25/+50), укажите причину, нажмите «Начислить».</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a4-3">
|
||||
<div class="tg-section-title">A4.3 Сброс прогресса</div>
|
||||
<p>Удаляет весь XP, монеты, достижения и историю начислений выбранного пользователя.</p>
|
||||
<div class="tg-warning"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Необратимо</div>Данные удаляются безвозвратно. Требуется подтверждение.</div></div>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a3')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Контент и доступ</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-a5')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Аудит и безопасность</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A5 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a5">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="file-text"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A5</div>
|
||||
<div class="tg-chapter-title">Аудит и безопасность</div>
|
||||
</div>
|
||||
<a href="/admin#sublog" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Аудит-лог</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a5-1">
|
||||
<div class="tg-section-title">A5.1 Аудит-лог</div>
|
||||
<p>Хронология всех административных действий: смены ролей, блокировки, начисления XP, сброс прогресса, изменения разрешений.</p>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a5-2">
|
||||
<div class="tg-section-title">A5.2 Разрешения (RBAC)</div>
|
||||
<p>Вкладка <b>«Разрешения»</b> — гранулярный контроль: per-role (для роли целиком) и per-user (для конкретного пользователя, приоритет выше).</p>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="info"></i></div><div class="tg-box-body"><div class="tg-box-label">free_student</div>Роль с минимальными правами — гибко расширяется разрешениями. Используйте для демо-аккаунтов.</div></div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a5-3">
|
||||
<div class="tg-section-title">A5.3 Модерация аватаров</div>
|
||||
<p>Ученики загружают фото — оно не показывается до подтверждения. Карточка ученика → вкладка «Аватар» → Принять / Отклонить.</p>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a4')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Геймификация (admin)</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-a6')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">System Health</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A6 -->
|
||||
<div class="tg-chapter admin-chapter" id="ch-a6">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="activity"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава A6</div>
|
||||
<div class="tg-chapter-title">System Health</div>
|
||||
</div>
|
||||
<a href="/admin#health" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Health</a>
|
||||
</div>
|
||||
<div class="tg-chapter-admin-header"><i data-lucide="shield"></i> Только для администратора</div>
|
||||
<div class="tg-section" id="s-a6-1">
|
||||
<div class="tg-section-title">A6.1 Метрики сервера</div>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="cpu"></i></div><div><div class="tg-tool-name">CPU / RAM</div><div class="tg-tool-desc">Текущая нагрузка и память</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="clock"></i></div><div><div class="tg-tool-name">Event Loop Lag</div><div class="tg-tool-desc">Задержка обработки запросов</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="bar-chart-3"></i></div><div><div class="tg-tool-name">Тренды</div><div class="tg-tool-desc">Canvas-графики нагрузки</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="alert-circle"></i></div><div><div class="tg-tool-name">Журнал ошибок</div><div class="tg-tool-desc">Последние ошибки с трейсом</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-section" id="s-a6-2">
|
||||
<div class="tg-section-title">A6.2 HTTP-статистика</div>
|
||||
<p>Самые медленные роуты (avgMs), самые частые, соотношение статус-кодов 2xx/4xx/5xx.</p>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="alert-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Event Loop Lag > 200ms</div>Сигнал перегрузки. Проверьте журнал ошибок и медленные роуты.</div></div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Вы изучили полное руководство. Все 17 + 6 admin-глав пройдены!</div></div>
|
||||
</div>
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a5')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Аудит и безопасность</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- #tg-admin-content -->
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div><!-- .sb-content -->
|
||||
@@ -1216,9 +1683,14 @@
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script>
|
||||
LS.initPage();
|
||||
const { isAdmin } = LS.initPage();
|
||||
lucide.createIcons();
|
||||
|
||||
if (isAdmin) {
|
||||
document.getElementById('tg-admin-content').style.display = '';
|
||||
document.getElementById('tg-nav-admin').style.display = '';
|
||||
}
|
||||
|
||||
/* ── Chapter metadata ── */
|
||||
const CHAPTERS = [
|
||||
{ id:'ch-1', label:'Быстрый старт', icon:'rocket', sections:['s-1-1','s-1-2','s-1-3','s-1-4'], sLabels:['Первый вход','Интерфейс','Первый класс','Чеклист'] },
|
||||
@@ -1234,26 +1706,49 @@
|
||||
{ id:'ch-11', label:'Учебники', icon:'book-open-text', sections:['s-11-1','s-11-2','s-11-3','s-11-4'], sLabels:['Каталог','Чтение и отметки','Назначить как ДЗ','Прогресс класса'] },
|
||||
{ id:'ch-12', label:'Экзамен 9 класс', icon:'clipboard-check', sections:['s-12-1','s-12-2'], sLabels:['Что внутри','Назначить вариант'] },
|
||||
{ id:'ch-13', label:'Мои ученики', icon:'user-plus', sections:['s-13-1','s-13-2','s-13-3','s-13-4'], sLabels:['Когда нужно','Добавить ученика','Назначение','Удаление и заметки'] },
|
||||
{ id:'ch-14', label:'Виртуальная лаборатория', icon:'flask-conical', sections:['s-14-1','s-14-2','s-14-3','s-14-4'], sLabels:['40 симуляций','Связь с учебниками','Стереометрия 3D','Управление (admin)'] },
|
||||
{ id:'ch-15', label:'Биохимия', icon:'atom', sections:['s-15-1','s-15-2','s-15-3'], sLabels:['Молекулярный редактор','Библиотека и свойства','Реакции и пути'] },
|
||||
{ id:'ch-16', label:'Геймификация и питомец', icon:'zap', sections:['s-16-1','s-16-2','s-16-3'], sLabels:['XP и достижения','Виртуальный питомец','Начисление XP (admin)'] },
|
||||
{ id:'ch-17', label:'Доступ к контенту', icon:'shield-check', sections:['s-17-1','s-17-2','s-17-3'], sLabels:['Открытие учебников классу','Feature Flags','System Health'] },
|
||||
];
|
||||
|
||||
const ADMIN_CHAPTERS = [
|
||||
{ id:'ch-a1', label:'Командный центр', icon:'layout-dashboard', sections:['s-a1-1','s-a1-2','s-a1-3'], sLabels:['Дашборд администратора','Очередь триажа','Лента и статистика'] },
|
||||
{ id:'ch-a2', label:'Управление пользователями', icon:'users', sections:['s-a2-1','s-a2-2','s-a2-3'], sLabels:['Список и фильтры','Карточка пользователя','Глобальный поиск'] },
|
||||
{ id:'ch-a3', label:'Контент и доступ', icon:'book-lock', sections:['s-a3-1','s-a3-2','s-a3-3'], sLabels:['Allowlist учебников','Симуляции','Feature Flags'] },
|
||||
{ id:'ch-a4', label:'Геймификация (admin)', icon:'zap', sections:['s-a4-1','s-a4-2','s-a4-3'], sLabels:['Статистика','Начисление XP/монет','Сброс прогресса'] },
|
||||
{ id:'ch-a5', label:'Аудит и безопасность', icon:'file-text', sections:['s-a5-1','s-a5-2','s-a5-3'], sLabels:['Аудит-лог','Разрешения RBAC','Модерация аватаров'] },
|
||||
{ id:'ch-a6', label:'System Health', icon:'activity', sections:['s-a6-1','s-a6-2'], sLabels:['Метрики сервера','HTTP-статистика'] },
|
||||
];
|
||||
|
||||
const ALL_CHAPTERS = () => isAdmin ? [...CHAPTERS, ...ADMIN_CHAPTERS] : CHAPTERS;
|
||||
|
||||
/* ── Render nav ── */
|
||||
const navContainer = document.getElementById('tg-nav-chapters');
|
||||
CHAPTERS.forEach((ch, ci) => {
|
||||
function buildNavItem(ch, label, container, extraClass) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tg-nav-chapter';
|
||||
div.className = 'tg-nav-chapter' + (extraClass ? ' ' + extraClass : '');
|
||||
div.dataset.ch = ch.id;
|
||||
div.innerHTML = `
|
||||
<button class="tg-nav-ch-btn" onclick="navChapterClick('${ch.id}',this)">
|
||||
<span class="tg-nav-ch-icon"><i data-lucide="${ch.icon}"></i></span>
|
||||
<span class="tg-nav-ch-label">${ci+1}. ${ch.label}</span>
|
||||
<span class="tg-nav-ch-label">${label}</span>
|
||||
<span class="tg-nav-ch-status"><i data-lucide="check"></i></span>
|
||||
<span class="tg-nav-ch-chevron"><i data-lucide="chevron-right"></i></span>
|
||||
</button>
|
||||
<div class="tg-nav-sections">
|
||||
${ch.sections.map((sid, si) => `<a class="tg-nav-sec-link" data-sec="${sid}" onclick="scrollToSection('${sid}')">${ch.sLabels[si]}</a>`).join('')}
|
||||
</div>`;
|
||||
navContainer.appendChild(div);
|
||||
});
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
const navContainer = document.getElementById('tg-nav-chapters');
|
||||
CHAPTERS.forEach((ch, ci) => buildNavItem(ch, (ci+1) + '. ' + ch.label, navContainer, ''));
|
||||
|
||||
if (isAdmin) {
|
||||
const adminNavContainer = document.getElementById('tg-nav-admin-chapters');
|
||||
ADMIN_CHAPTERS.forEach((ch, ci) => buildNavItem(ch, 'A' + (ci+1) + '. ' + ch.label, adminNavContainer, 'admin'));
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
/* ── Chapter switching ── */
|
||||
@@ -1294,7 +1789,7 @@
|
||||
|
||||
function scrollToChapter(chId) { showChapter(chId); }
|
||||
function scrollToSection(secId) {
|
||||
const ch = CHAPTERS.find(c => c.sections.includes(secId));
|
||||
const ch = ALL_CHAPTERS().find(c => c.sections.includes(secId));
|
||||
if (ch) showChapter(ch.id, secId);
|
||||
}
|
||||
|
||||
@@ -1320,8 +1815,9 @@
|
||||
div.querySelector('.tg-nav-ch-btn').classList.toggle('read', readChapters.includes(div.dataset.ch));
|
||||
});
|
||||
const n = readChapters.length;
|
||||
document.getElementById('tg-prog-text').textContent = `${n} из ${CHAPTERS.length} глав прочитано`;
|
||||
progBar.style.width = Math.round(n / CHAPTERS.length * 100) + '%';
|
||||
const total = ALL_CHAPTERS().length;
|
||||
document.getElementById('tg-prog-text').textContent = `${n} из ${total} глав прочитано`;
|
||||
progBar.style.width = Math.round(n / total * 100) + '%';
|
||||
}
|
||||
updateReadUI();
|
||||
|
||||
@@ -1374,7 +1870,7 @@
|
||||
|
||||
/* ── Init from hash or default ch-1 ── */
|
||||
const initHash = location.hash.replace('#', '');
|
||||
showChapter(CHAPTERS.find(c => c.id === initHash) ? initHash : 'ch-1');
|
||||
showChapter(ALL_CHAPTERS().find(c => c.id === initHash) ? initHash : 'ch-1');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -164,6 +164,13 @@
|
||||
width:auto; min-width:42px; padding:9px 12px;
|
||||
flex:0 0 auto;
|
||||
}
|
||||
.tb-lab-btn {
|
||||
width:auto; min-width:42px; padding:9px 12px;
|
||||
flex:0 0 auto; white-space:nowrap;
|
||||
font-variant-numeric: tabular-nums; font-weight:800;
|
||||
}
|
||||
.tb-lab-btn svg { stroke:var(--violet); flex-shrink:0; }
|
||||
.tb-lab-btn:hover { border-color:var(--violet); background:rgba(155,93,229,.08); }
|
||||
|
||||
.tb-empty {
|
||||
grid-column: 1 / -1;
|
||||
@@ -521,9 +528,8 @@
|
||||
${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||||
</button>` : ''}
|
||||
${(labLinks[t.slug] && labLinks[t.slug].length) ? `<button class="tb-btn tb-lab-btn" onclick="openLabSim('${esc(labLinks[t.slug][0].id)}', event)" title="Открыть связанную симуляцию в лаборатории">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>
|
||||
В лабораторию${labLinks[t.slug].length > 1 ? ' (' + labLinks[t.slug].length + ')' : ''}
|
||||
${(labLinks[t.slug] && labLinks[t.slug].length) ? `<button class="tb-btn tb-lab-btn" onclick="openLabSim('${esc(labLinks[t.slug][0].id)}', event)" title="В лабораторию${labLinks[t.slug].length > 1 ? ' — связанных симуляций: ' + labLinks[t.slug].length : ''}" aria-label="В лабораторию">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>${labLinks[t.slug].length > 1 ? labLinks[t.slug].length : ''}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="/css/chem8-textbook.css">
|
||||
<style>/* Химия 7 · тема главы 1 — emerald (переопределяет amber chem8-textbook.css) */
|
||||
:root{--bg:#f0fdf4;--border:#bbf7d0;--pri:#059669;--pri-d:#047857;--pri-l:#34d399;--pri-soft:#d1fae5;--sec-acc:#059669;--sec-acc-d:#047857;--sec-acc-soft:#d1fae5;--warn:#047857;--warn-bg:#d1fae5}
|
||||
html.dark{--bg:#0a1a12;--border:#1f4030;--pri-soft:rgba(5,150,105,.18);--sec-acc-soft:rgba(5,150,105,.18)}
|
||||
.hdr{background:linear-gradient(110deg,#065f46 0%,#059669 55%,#6ee7b7 100%)}
|
||||
.hero{background:linear-gradient(135deg,var(--pri-soft),rgba(110,231,183,.12))}
|
||||
.para-hero{background:linear-gradient(135deg,#064e3b,#059669 55%,#34d399)}</style>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="/js/api.js" defer></script>
|
||||
@@ -17,6 +23,7 @@
|
||||
<script src="/js/biochem-core.js" defer></script>
|
||||
<script src="/js/chem8_svg.js" defer></script>
|
||||
<script src="/js/chem7_svg.js" defer></script>
|
||||
<script src="/js/chem7_anim.js" defer></script>
|
||||
<script src="/js/chem7_ch1_widgets.js" defer></script>
|
||||
<script src="/js/chem8_engine.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="/css/chem8-textbook.css">
|
||||
<style>/* Химия 7 · тема главы 2 — cyan (переопределяет amber chem8-textbook.css) */
|
||||
:root{--bg:#ecfeff;--border:#a5f3fc;--pri:#0891b2;--pri-d:#0e7490;--pri-l:#22d3ee;--pri-soft:#cffafe;--sec-acc:#0891b2;--sec-acc-d:#0e7490;--sec-acc-soft:#cffafe;--warn:#0e7490;--warn-bg:#cffafe}
|
||||
html.dark{--bg:#08191c;--border:#164e5b;--pri-soft:rgba(8,145,178,.18);--sec-acc-soft:rgba(8,145,178,.18)}
|
||||
.hdr{background:linear-gradient(110deg,#164e63 0%,#0891b2 55%,#22d3ee 100%)}
|
||||
.hero{background:linear-gradient(135deg,var(--pri-soft),rgba(34,211,238,.12))}
|
||||
.para-hero{background:linear-gradient(135deg,#164e63,#0891b2 55%,#22d3ee)}</style>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="/js/api.js" defer></script>
|
||||
@@ -17,6 +23,7 @@
|
||||
<script src="/js/biochem-core.js" defer></script>
|
||||
<script src="/js/chem8_svg.js" defer></script>
|
||||
<script src="/js/chem7_svg.js" defer></script>
|
||||
<script src="/js/chem7_anim.js" defer></script>
|
||||
<script src="/js/chem7_ch2_widgets.js" defer></script>
|
||||
<script src="/js/chem8_engine.js" defer></script>
|
||||
</head>
|
||||
@@ -90,18 +97,28 @@ window.PARAS = [
|
||||
];
|
||||
|
||||
window.ACH_LABELS = { start:'Начало главы 2!', p13_done:'§13 изучен!', lo2_done:'Лабораторный опыт 2 выполнен!',
|
||||
p14_done:'§14 изучен!', p15_done:'§15 изучен!', final2_tasks:'Глава 2 пройдена!' };
|
||||
p14_done:'§14 изучен!', p15_done:'§15 изучен!',
|
||||
p16_done:'§16 изучен!', p17_done:'§17 изучен!', pr2_done:'Практическая работа 2 выполнена!',
|
||||
final2_tasks:'Глава 2 пройдена! Вы — Повелитель кислорода!' };
|
||||
window.SIDEBARS = {
|
||||
p13:{ title:'Шпаргалка §13', rows:[['Воздух','смесь газов'],['$N_2$','≈ 78 %'],['$O_2$','≈ 21 %']] },
|
||||
lo2:{ title:'Лаб. опыт 2', rows:[['Прибор','пробирка + трубка'],['Собирание','воздуха или воды']] },
|
||||
p14:{ title:'Шпаргалка §14', rows:[['Элемент','O, $Z=8$'],['Вещество','$O_2$'],['Озон','$O_3$']] },
|
||||
p15:{ title:'Шпаргалка §15', rows:[['Горение','+ $O_2$'],['Продукт','оксид'],['Окисление','медленное и быстрое']] }
|
||||
p15:{ title:'Шпаргалка §15', rows:[['Горение','+ $O_2$'],['Продукт','оксид'],['Окисление','медленное и быстрое']] },
|
||||
p16:{ title:'Шпаргалка §16', rows:[['Оксид','Э + O'],['O','валентность II'],['Формула','$Э_xO_y$']] },
|
||||
p17:{ title:'Шпаргалка §17', rows:[['Лаборатория','разложение'],['Катализатор','ускоряет, не тратится'],['Промышленность','из воздуха']] },
|
||||
pr2:{ title:'Практическая 2', rows:[['Получить','разложением'],['Доказать','тлеющая лучинка']] },
|
||||
final2:{ title:'Финал главы 2', rows:[['§§13–17','кислород'],['Награда','ачивка + XP']] }
|
||||
};
|
||||
window.TIPS = [
|
||||
{ sec:'p13', html:'Воздух — <b>смесь</b> газов: примерно $78\\,\\%$ азота $N_2$ и $21\\,\\%$ кислорода $O_2$, около $1\\,\\%$ — другие газы.' },
|
||||
{ sec:'lo2', html:'Газ, который <b>тяжелее</b> воздуха (как $O_2$), собирают в сосуд отверстием <b>вверх</b>; <b>легче</b> воздуха ($H_2$) — отверстием вниз; нерастворимый — вытеснением воды.' },
|
||||
{ sec:'p14', html:'$O$ — <b>элемент</b> (атом в составе веществ). $O_2$ — <b>простое вещество</b>. Кислород $O_2$ и озон $O_3$ — разные простые вещества одного элемента.' },
|
||||
{ sec:'p15', html:'При горении вещество соединяется с кислородом — образуется <b>оксид</b>. Реакции с кислородом называют реакциями <b>окисления</b>.' }
|
||||
{ sec:'p15', html:'При горении вещество соединяется с кислородом — образуется <b>оксид</b>. Реакции с кислородом называют реакциями <b>окисления</b>.' },
|
||||
{ sec:'p16', html:'<b>Оксид</b> — сложное вещество из двух элементов, один из которых кислород (валентность II). Формулу составляют по валентности: оксид алюминия — $Al_2O_3$.' },
|
||||
{ sec:'p17', html:'В лаборатории $O_2$ получают <b>разложением</b> веществ, богатых кислородом. <b>Катализатор</b> ($MnO_2$) ускоряет реакцию, но сам не расходуется.' },
|
||||
{ sec:'pr2', html:'Кислород доказывают <b>тлеющей лучинкой</b>: в кислороде она ярко вспыхивает.' },
|
||||
{ sec:'final2', html:'Собери всё: состав воздуха, кислород-элемент и $O_2$, горение и оксиды, получение кислорода и катализатор.' }
|
||||
];
|
||||
|
||||
window.POOLS = {
|
||||
@@ -122,6 +139,26 @@ window.POOLS = {
|
||||
{q:'При горении серы в кислороде образуется…',opts:['$\\text{SO}_2$','$\\text{H}_2\\text{S}$','$\\text{S}$','$\\text{SO}_3$ только'],a:0,ex:'$S+O_2=SO_2$ (с резким запахом).'},
|
||||
{q:'Реакция $\\text{C}+\\text{O}_2=\\text{CO}_2$ относится к реакциям…',opts:['Разложения','Соединения','Обмена','Замещения'],a:1,ex:'Из двух веществ одно — соединение.'},
|
||||
{q:'В уравнении $2\\text{Mg}+\\text{O}_2=2\\text{MgO}$ коэффициент перед $\\text{MgO}$ равен…',hint:'смотри на оксид',unit:'',a:2,ex:'Коэффициент 2.'}
|
||||
],
|
||||
p16:[
|
||||
{q:'Оксид — это…',opts:['Соль','Сложное вещество из двух элементов, один из которых кислород','Простое вещество','Кислота'],a:1,ex:'Оксид — соединение элемента с кислородом.'},
|
||||
{q:'Какое вещество является оксидом?',opts:['NaCl','CO₂','H₂SO₄','HCl'],a:1,ex:'$CO_2$ — оксид углерода(IV).'},
|
||||
{q:'Какова валентность меди в оксиде $\\text{CuO}$ (кислород — II)?',hint:'равна валентности O',unit:'',a:2,ex:'Cu — II.'},
|
||||
{q:'Какова формула оксида фосфора, в котором фосфор пятивалентен?',opts:['PO','P₂O₅','P₂O₃','PO₂'],a:1,ex:'P(V), O(II) → $P_2O_5$.'}
|
||||
],
|
||||
p17:[
|
||||
{q:'Как получают кислород в лаборатории?',opts:['Разложением веществ, богатых кислородом','Из поваренной соли','Сжиганием угля','Из песка'],a:0,ex:'Например, разложением $KMnO_4$ или $H_2O_2$.'},
|
||||
{q:'Катализатор — это вещество, которое…',opts:['Расходуется в реакции','Ускоряет реакцию и не расходуется','Замедляет реакцию','Само горит'],a:1,ex:'Катализатор ускоряет реакцию, оставаясь неизменным.'},
|
||||
{q:'Реакция $2\\text{KMnO}_4=\\text{K}_2\\text{MnO}_4+\\text{MnO}_2+\\text{O}_2$ — это реакция…',opts:['Соединения','Разложения','Замещения','Обмена'],a:1,ex:'Из одного вещества — несколько: разложение.'},
|
||||
{q:'Откуда в промышленности получают кислород?',opts:['Из воздуха','Из поваренной соли','Из угля','Из песка'],a:0,ex:'Кислород выделяют из жидкого воздуха.'}
|
||||
],
|
||||
final2:[
|
||||
{q:'Объёмная доля кислорода в воздухе (%)?',hint:'≈ 21',unit:'%',a:21,ex:'21 %.'},
|
||||
{q:'Валентность серы в оксиде $\\text{SO}_2$ (O — II)?',hint:'$2\\cdot\\text{II}$',unit:'',a:4,ex:'IV.'},
|
||||
{q:'В уравнении $2\\text{Mg}+\\text{O}_2=2\\text{MgO}$ коэффициент перед $\\text{O}_2$?',hint:'',unit:'',a:1,ex:'1.'},
|
||||
{q:'Продукт горения углерода в кислороде — это…',opts:['CO₂','CO только','C','H₂O'],a:0,ex:'$C+O_2=CO_2$.'},
|
||||
{q:'Катализатор в реакции…',opts:['Расходуется','Не расходуется','Сгорает','Испаряется'],a:1,ex:'Катализатор не расходуется.'},
|
||||
{q:'Сколько атомов кислорода в формуле оксида фосфора $\\text{P}_2\\text{O}_5$?',hint:'индекс при O',unit:'',a:5,ex:'5.'}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -195,6 +232,66 @@ function build_p15(){
|
||||
wireReadBtn('p15');
|
||||
}
|
||||
|
||||
function build_p16(){
|
||||
document.getElementById('p16-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 16 · Химия 7</div><h2>Оксиды</h2>'
|
||||
+'<div class="ph-formula">$Э_x\\text{O}_y$</div>'
|
||||
+'<div class="ph-desc">Что такое оксиды, как составляют их формулы и названия.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">оксид</span><span class="ph-tag">валентность O — II</span></div></div>'
|
||||
+makeCard('theory','Что такое оксид','§16','<div class="def-box"><b>Оксид</b> — сложное вещество, состоящее из двух элементов, один из которых — кислород (в оксидах он имеет валентность II).</div>'
|
||||
+'<p>Примеры: $\\text{CuO}$, $\\text{CO}_2$, $\\text{SO}_2$, $\\text{P}_2\\text{O}_5$, $\\text{Fe}_3\\text{O}_4$, $\\text{H}_2\\text{O}$, $\\text{CaO}$. Формулу оксида составляют по валентности, как и любую формулу.</p>')
|
||||
+makeCard('example','Названия оксидов',null,'<p>Название: «оксид» + название элемента. Если элемент имеет переменную валентность, её указывают римской цифрой: $\\text{CuO}$ — оксид меди(II), $\\text{CO}_2$ — оксид углерода(IV).</p>')
|
||||
+wgt('Конструктор оксида','<div id="p16-bld"></div>')
|
||||
+wgt('Распредели: оксид или не оксид?','<div id="p16-cls"></div>')
|
||||
+rememberBox(['Оксид — соединение элемента с кислородом.','Кислород в оксидах двухвалентен (II).','Название: «оксид» + элемент (+ валентность, если переменная).'])
|
||||
+qList(['Дай определение оксида.','Составь формулу оксида кальция (Ca — II).','Назови оксид $\\text{SO}_2$.'])
|
||||
+secNav('p15','p17')+readButton('p16');
|
||||
wireReadBtn('p16');
|
||||
}
|
||||
|
||||
function build_p17(){
|
||||
document.getElementById('p17-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 17 · Химия 7</div><h2>Получение кислорода</h2>'
|
||||
+'<div class="ph-desc">Как получают кислород в лаборатории и в промышленности и что такое катализатор.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">разложение</span><span class="ph-tag">катализатор</span></div></div>'
|
||||
+makeCard('theory','Получение в лаборатории','§17','<p>В лаборатории кислород получают <b>разложением</b> веществ, богатых кислородом: перманганата калия $\\text{KMnO}_4$ (при нагревании) или пероксида водорода $\\text{H}_2\\text{O}_2$.</p>'
|
||||
+'<div class="def-box"><b>Реакция разложения</b> — реакция, в которой из одного сложного вещества образуется несколько других веществ.</div>')
|
||||
+makeCard('rule','Катализатор','§17','<p>Разложение $\\text{H}_2\\text{O}_2$ ускоряет <b>катализатор</b> — оксид марганца(IV) $\\text{MnO}_2$.</p>'
|
||||
+'<div class="def-box"><b>Катализатор</b> — вещество, которое ускоряет химическую реакцию, но само в ней не расходуется.</div>'
|
||||
+'<p>В промышленности кислород получают из воздуха (разделяя сжиженный воздух).</p>')
|
||||
+wgt('Схема получения кислорода','<div id="p17-prod"></div>')
|
||||
+rememberBox(['В лаборатории $O_2$ — разложением $KMnO_4$ или $H_2O_2$.','Катализатор ускоряет реакцию и не расходуется.','В промышленности кислород получают из воздуха.'])
|
||||
+qList(['Что такое реакция разложения?','Какую роль играет $\\text{MnO}_2$ при разложении пероксида водорода?','Откуда получают кислород в промышленности?'])
|
||||
+secNav('p16','pr2')+readButton('p17');
|
||||
wireReadBtn('p17');
|
||||
}
|
||||
|
||||
function build_pr2(){
|
||||
document.getElementById('pr2-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Практическая работа 2</div><h2>Получение кислорода и изучение его свойств</h2>'
|
||||
+'<div class="ph-desc">Получить кислород, собрать его и доказать, что это именно кислород.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>Собери прибор для получения газа (пробирка с газоотводной трубкой).</li><li>Получи кислород разложением вещества, богатого кислородом.</li><li>Собери кислород в сосуд (вытеснением воздуха — отверстием вверх, или вытеснением воды).</li><li>Внеси в сосуд <b>тлеющую лучинку</b> — она ярко вспыхнет: это доказывает, что газ — кислород.</li><li>Сделай вывод о свойствах кислорода.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Нагревай пробирку осторожно; не направляй её отверстием на людей.</div>')
|
||||
+wgt('Докажи, что газ — кислород','<div id="pr2-test"></div>')
|
||||
+secNav('p17','final2')+readButton('pr2');
|
||||
wireReadBtn('pr2');
|
||||
}
|
||||
|
||||
function build_final2(){
|
||||
document.getElementById('final2-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Финал главы 2</div><h2>Босс: кислород</h2>'
|
||||
+'<div class="ph-formula">воздух · $O_2$ · горение · оксиды · получение</div>'
|
||||
+'<div class="ph-desc">Шесть задач на всю главу. Реши все — получи звание «Повелитель кислорода».</div></div>'
|
||||
+makeCard('rule','Шпаргалка главы 2',null,'<ul>'
|
||||
+'<li>Воздух — смесь: $N_2$ ≈ 78 %, $O_2$ ≈ 21 %.</li>'
|
||||
+'<li>$O$ — элемент; $O_2$ и $O_3$ — простые вещества.</li>'
|
||||
+'<li>Горение: вещество $+ O_2 \\to$ <b>оксид</b>; окисление бывает быстрым и медленным.</li>'
|
||||
+'<li>Оксид — соединение с кислородом (O — II).</li>'
|
||||
+'<li>Получение $O_2$ — разложением ($KMnO_4$, $H_2O_2$ + катализатор $MnO_2$).</li></ul>')
|
||||
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши задачи ниже — за каждую +5 XP, за полный разгром босса — звание и бонус.</p>'
|
||||
+secNav('pr2',null);
|
||||
}
|
||||
|
||||
/* заглушки для ещё не наполненных § (следующая волна) */
|
||||
(function(){
|
||||
var P = window.PARAS, B = {};
|
||||
@@ -221,6 +318,10 @@ window.BUILDERS.p13 = build_p13;
|
||||
window.BUILDERS.lo2 = build_lo2;
|
||||
window.BUILDERS.p14 = build_p14;
|
||||
window.BUILDERS.p15 = build_p15;
|
||||
window.BUILDERS.p16 = build_p16;
|
||||
window.BUILDERS.p17 = build_p17;
|
||||
window.BUILDERS.pr2 = build_pr2;
|
||||
window.BUILDERS.final2 = build_final2;
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="/css/chem8-textbook.css">
|
||||
<style>/* Химия 7 · тема главы 3 — violet (переопределяет amber chem8-textbook.css) */
|
||||
:root{--bg:#f5f3ff;--border:#ddd6fe;--pri:#7c3aed;--pri-d:#6d28d9;--pri-l:#a78bfa;--pri-soft:#ede9fe;--sec-acc:#7c3aed;--sec-acc-d:#6d28d9;--sec-acc-soft:#ede9fe;--warn:#6d28d9;--warn-bg:#ede9fe}
|
||||
html.dark{--bg:#140a24;--border:#3b2a63;--pri-soft:rgba(124,58,237,.18);--sec-acc-soft:rgba(124,58,237,.18)}
|
||||
.hdr{background:linear-gradient(110deg,#4c1d95 0%,#7c3aed 55%,#a78bfa 100%)}
|
||||
.hero{background:linear-gradient(135deg,var(--pri-soft),rgba(167,139,250,.12))}
|
||||
.para-hero{background:linear-gradient(135deg,#4c1d95,#7c3aed 55%,#a78bfa)}</style>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="/js/api.js" defer></script>
|
||||
@@ -17,6 +23,8 @@
|
||||
<script src="/js/biochem-core.js" defer></script>
|
||||
<script src="/js/chem8_svg.js" defer></script>
|
||||
<script src="/js/chem7_svg.js" defer></script>
|
||||
<script src="/js/chem7_anim.js" defer></script>
|
||||
<script src="/js/chem7_ch3_widgets.js" defer></script>
|
||||
<script src="/js/chem8_engine.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -90,11 +98,214 @@ window.PARAS = [
|
||||
{id:'final3', num:'★', name:'Финал главы', sub:'босс · ачивка', final:true}
|
||||
];
|
||||
|
||||
window.ACH_LABELS = { start:'Начало главы 3!', final3_tasks:'Глава 3 пройдена!' };
|
||||
window.SIDEBARS = { p18:{ title:'Глава 3 · Химия 7', rows:[['Раздел','Водород'],['§§','18–22'],['Лаб/ПР','ЛО 3,4 · ПР 3']] } };
|
||||
window.TIPS = [{ sec:'p18', html:'Глава наполняется содержанием по фазам. Сейчас доступны навигация по параграфам и отметка о прочтении (+10 XP).' }];
|
||||
window.ACH_LABELS = { start:'Начало главы 3!', p18_done:'§18 изучен!', p19_done:'§19 изучен!',
|
||||
p20_done:'§20 изучен!', lo3_done:'Лабораторный опыт 3 выполнен!',
|
||||
p21_done:'§21 изучен!', lo4_done:'Лабораторный опыт 4 выполнен!', p22_done:'§22 изучен!', pr3_done:'Практическая работа 3 выполнена!',
|
||||
final3_tasks:'Глава 3 пройдена! Вы — Знаток водорода!' };
|
||||
window.SIDEBARS = {
|
||||
p18:{ title:'Шпаргалка §18', rows:[['$H$','элемент, $Z=1$'],['$H_2$','самый лёгкий газ'],['Вселенная','самый частый элемент']] },
|
||||
p19:{ title:'Шпаргалка §19', rows:[['Горение','$+O_2 \\to H_2O$'],['Гремучий газ','$H_2$ + воздух'],['Восстановитель','отнимает O у оксида']] },
|
||||
p20:{ title:'Шпаргалка §20', rows:[['Кислота','H + остаток'],['Примеры','$HCl$, $H_2SO_4$'],['Лакмус','в кислоте красный']] },
|
||||
lo3:{ title:'Лаб. опыт 3', rows:[['Лакмус','красный'],['Метилоранж','розово-красный']] },
|
||||
p21:{ title:'Шпаргалка §21', rows:[['Me + кислота','соль + $H_2\\uparrow$'],['Ряд активности','до $H_2$ — вытесняют'],['Cu, Ag','не реагируют']] },
|
||||
lo4:{ title:'Лаб. опыт 4', rows:[['Zn, Fe, Mg','пузырьки $H_2$'],['Cu','реакции нет']] },
|
||||
p22:{ title:'Шпаргалка §22', rows:[['Соль','металл + остаток'],['Замещение','H на металл'],['Названия','хлорид, сульфат…']] },
|
||||
pr3:{ title:'Практическая 3', rows:[['$Zn+HCl$','$\\to H_2\\uparrow$'],['Чистота','гремучий газ']] },
|
||||
final3:{ title:'Финал главы 3', rows:[['§§18–22','водород'],['Награда','ачивка + XP']] }
|
||||
};
|
||||
window.TIPS = [
|
||||
{ sec:'p18', html:'$H$ — самый лёгкий элемент ($A_r=1$). Простое вещество $H_2$ — самый лёгкий газ, легче воздуха. Во Вселенной водород — самый распространённый элемент.' },
|
||||
{ sec:'p19', html:'Водород горит в кислороде, образуя воду. С оксидами он ведёт себя как <b>восстановитель</b>: $H_2 + CuO = Cu + H_2O$.' },
|
||||
{ sec:'p20', html:'<b>Кислота</b> = атомы водорода + кислотный остаток. <b>Индикаторы</b> меняют цвет: лакмус в кислоте — красный, метилоранж — розово-красный.' },
|
||||
{ sec:'lo3', html:'В кислоте лакмус становится красным, а метилоранж — розово-красным. Так обнаруживают кислоту.' },
|
||||
{ sec:'p21', html:'Металл + кислота → соль + водород. Реагируют только металлы, стоящие в <b>ряду активности левее $H_2$</b> (Zn, Fe, Mg). Медь и серебро водород из кислот не вытесняют.' },
|
||||
{ sec:'lo4', html:'Цинк, железо, магний с соляной и серной кислотами дают пузырьки водорода; медь — нет (стоит правее $H_2$).' },
|
||||
{ sec:'p22', html:'<b>Соль</b> = металл + кислотный остаток. Она образуется, когда металл <b>замещает</b> водород в кислоте: $Zn + 2HCl = ZnCl_2 + H_2$.' },
|
||||
{ sec:'pr3', html:'Водород получают реакцией $Zn + HCl$; чистоту проверяют поджиганием (хлопок — нечисто, спокойное горение — чисто).' },
|
||||
{ sec:'final3', html:'Собери всё: водород и его свойства, кислоты и индикаторы, ряд активности, соли как продукты замещения.' }
|
||||
];
|
||||
|
||||
/* Phase 0: заглушки-builder'ы из PARAS (теория и интерактивы добавляются в фазах 1–4). */
|
||||
window.POOLS = {
|
||||
p18:[
|
||||
{q:'Водород — это самый…',opts:['Тяжёлый газ','Лёгкий газ','Активный металл','Ядовитый газ'],a:1,ex:'$H_2$ — самый лёгкий газ.'},
|
||||
{q:'Чему равна относительная атомная масса водорода $A_r(\\text{H})$?',hint:'из таблицы',unit:'',a:1,ex:'$A_r(\\text{H})=1$.'},
|
||||
{q:'$\\text{H}_2$ — это…',opts:['Химический элемент','Простое вещество','Сложное вещество','Смесь'],a:1,ex:'Молекула из двух атомов одного элемента — простое вещество.'},
|
||||
{q:'Где водород встречается чаще всего?',opts:['В земной коре','Во Вселенной','В металлах','В песке'],a:1,ex:'Во Вселенной водород — самый распространённый элемент.'}
|
||||
],
|
||||
p19:[
|
||||
{q:'При горении водорода в кислороде образуется…',opts:['Углекислый газ','Вода','Оксид меди','Соль'],a:1,ex:'$2H_2+O_2=2H_2O$.'},
|
||||
{q:'Смесь водорода с воздухом называют…',opts:['Угарным газом','Гремучим газом','Озоном','Сухим льдом'],a:1,ex:'Гремучий газ взрывоопасен.'},
|
||||
{q:'В реакции $\\text{H}_2+\\text{CuO}=\\text{Cu}+\\text{H}_2\\text{O}$ водород является…',opts:['Окислителем','Восстановителем','Катализатором','Индикатором'],a:1,ex:'Водород отнимает кислород — восстановитель.'},
|
||||
{q:'Коэффициент перед $\\text{H}_2\\text{O}$ в $2\\text{H}_2+\\text{O}_2=2\\text{H}_2\\text{O}$?',hint:'',unit:'',a:2,ex:'2.'}
|
||||
],
|
||||
p20:[
|
||||
{q:'Из чего состоит кислота?',opts:['Из металла и кислорода','Из атомов водорода и кислотного остатка','Из двух металлов','Из воды и соли'],a:1,ex:'Кислота = водород + кислотный остаток.'},
|
||||
{q:'Какого цвета становится лакмус в кислоте?',opts:['Синего','Красного','Зелёного','Жёлтого'],a:1,ex:'Лакмус в кислоте — красный.'},
|
||||
{q:'Какова формула серной кислоты?',opts:['HCl','H₂SO₄','HNO₃','H₂CO₃'],a:1,ex:'Серная кислота — $H_2SO_4$.'},
|
||||
{q:'Индикатор — это вещество, которое…',opts:['Ускоряет реакцию','Меняет цвет в кислоте или щёлочи','Растворяет металлы','Выделяет газ'],a:1,ex:'Индикаторы обнаруживают кислоты и щёлочи по изменению цвета.'}
|
||||
],
|
||||
p21:[
|
||||
{q:'Какой газ выделяется при реакции активного металла с кислотой?',opts:['Кислород','Водород','Углекислый газ','Азот'],a:1,ex:'Металл вытесняет водород: соль + $H_2\\uparrow$.'},
|
||||
{q:'Какой металл НЕ вытесняет водород из соляной кислоты?',opts:['Цинк','Магний','Медь','Железо'],a:2,ex:'Медь стоит правее $H_2$ — не реагирует.'},
|
||||
{q:'Продукты реакции $\\text{Zn}+2\\text{HCl}$ — это…',opts:['$ZnCl_2$ и $H_2$','$ZnO$ и вода','$Zn$ и $Cl_2$','только соль'],a:0,ex:'$Zn+2HCl=ZnCl_2+H_2\\uparrow$.'},
|
||||
{q:'Сколько веществ образуется в реакции $\\text{Zn}+\\text{H}_2\\text{SO}_4=\\text{ZnSO}_4+\\text{H}_2$?',hint:'соль и газ',unit:'',a:2,ex:'Соль и водород — 2 вещества.'}
|
||||
],
|
||||
p22:[
|
||||
{q:'Из чего состоит соль?',opts:['Из водорода и остатка','Из металла и кислотного остатка','Из двух металлов','Из воды и кислоты'],a:1,ex:'Соль = металл + кислотный остаток.'},
|
||||
{q:'Соль $\\text{NaCl}$ — продукт замещения водорода в кислоте…',opts:['$H_2SO_4$','$HCl$','$HNO_3$','$H_2CO_3$'],a:1,ex:'В $HCl$ водород заместился натрием → $NaCl$.'},
|
||||
{q:'Какова формула сульфата натрия?',opts:['NaSO4','Na₂SO₄','Na₂SO₃','NaS'],a:1,ex:'Na(I), $SO_4$(II) → $Na_2SO_4$.'},
|
||||
{q:'Реакция $\\text{Zn}+2\\text{HCl}=\\text{ZnCl}_2+\\text{H}_2$ — это реакция…',opts:['Соединения','Разложения','Замещения','Обмена'],a:2,ex:'Металл замещает водород — реакция замещения.'}
|
||||
],
|
||||
final3:[
|
||||
{q:'$A_r(\\text{H})=?$',hint:'из таблицы',unit:'',a:1,ex:'1.'},
|
||||
{q:'Продукт горения водорода в кислороде — это…',opts:['Вода','Углекислый газ','Оксид меди','Соль'],a:0,ex:'$2H_2+O_2=2H_2O$.'},
|
||||
{q:'Цвет лакмуса в кислоте?',opts:['Синий','Красный','Зелёный','Жёлтый'],a:1,ex:'Красный.'},
|
||||
{q:'Сколько из металлов Mg, Cu, Zn вытесняют водород из кислоты?',hint:'Cu — правее $H_2$',unit:'',a:2,ex:'Mg и Zn → 2.'},
|
||||
{q:'Соль — это…',opts:['Металл + кислород','Металл + кислотный остаток','Водород + остаток','Два неметалла'],a:1,ex:'Металл + кислотный остаток.'},
|
||||
{q:'Формула серной кислоты?',opts:['HCl','H₂SO₄','HNO₃','H₂CO₃'],a:1,ex:'$H_2SO_4$.'}
|
||||
]
|
||||
};
|
||||
|
||||
function rememberBox(items){
|
||||
return '<div class="remember-box"><div class="remember-box-title">'
|
||||
+'<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'
|
||||
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>';
|
||||
}
|
||||
function qList(items){
|
||||
return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'
|
||||
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>';
|
||||
}
|
||||
function wgt(title, inner){
|
||||
return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>';
|
||||
}
|
||||
|
||||
function build_p18(){
|
||||
document.getElementById('p18-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 18 · Химия 7</div><h2>Водород — химический элемент и простое вещество</h2>'
|
||||
+'<div class="ph-formula">$\\text{H}_2$</div>'
|
||||
+'<div class="ph-desc">Самый лёгкий элемент Вселенной и его простое вещество.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">$H$</span><span class="ph-tag">$H_2$</span></div></div>'
|
||||
+makeCard('theory','Элемент и простое вещество','§18','<p><b>Водород как элемент</b> — атомы H ($Z=1$, $A_r=1$), самый лёгкий из всех элементов. Атомы водорода входят в состав воды и множества других веществ.</p>'
|
||||
+'<div class="def-box"><b>Водород как простое вещество</b> — газ $\\text{H}_2$ (молекула из двух атомов). Это самый лёгкий газ, легче воздуха.</div>')
|
||||
+makeCard('theory','Свойства и нахождение','§18','<p>$\\text{H}_2$ — газ без цвета и запаха, мало растворим в воде. В свободном виде на Земле водорода почти нет, но в составе веществ (особенно воды) его много. Во Вселенной водород — самый распространённый элемент.</p>')
|
||||
+wgt('Паспорт водорода','<div id="p18-card"></div>')
|
||||
+rememberBox(['$H$ — самый лёгкий элемент ($A_r=1$).','$H_2$ — самый лёгкий газ, легче воздуха.','Во Вселенной водород — самый распространённый элемент.'])
|
||||
+qList(['Чем водород-элемент отличается от простого вещества $H_2$?','Назови физические свойства водорода.','Где на Земле находится водород?'])
|
||||
+secNav(null,'p19')+readButton('p18');
|
||||
wireReadBtn('p18');
|
||||
}
|
||||
|
||||
function build_p19(){
|
||||
document.getElementById('p19-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 19 · Химия 7</div><h2>Химические свойства водорода</h2>'
|
||||
+'<div class="ph-formula">$2\\text{H}_2+\\text{O}_2=2\\text{H}_2\\text{O}$</div>'
|
||||
+'<div class="ph-desc">Как водород горит и почему его называют восстановителем.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">горение</span><span class="ph-tag">восстановитель</span></div></div>'
|
||||
+makeCard('theory','Горение водорода','§19','<p>Водород горит в кислороде, образуя воду: $2\\text{H}_2+\\text{O}_2=2\\text{H}_2\\text{O}$. Смесь водорода с воздухом (или кислородом) — «<b>гремучий газ</b>» — взрывоопасна, поэтому перед поджиганием водород проверяют на чистоту.</p>')
|
||||
+makeCard('theory','Водород — восстановитель','§19','<div class="def-box">Водород способен <b>отнимать кислород</b> у оксидов металлов: $\\text{H}_2+\\text{CuO}=\\text{Cu}+\\text{H}_2\\text{O}$. Чёрный оксид меди превращается в красную медь. Вещество, отнимающее кислород, называют <b>восстановителем</b>.</div>')
|
||||
+wgt('Реакции водорода','<div id="p19-rx"></div>')
|
||||
+rememberBox(['Водород горит в кислороде → образуется вода.','Смесь $H_2$ с воздухом — гремучий газ, взрывается.','Водород — восстановитель: отнимает кислород у оксидов.'])
|
||||
+qList(['Запиши уравнение горения водорода.','Почему водород называют восстановителем?','Что такое гремучий газ?'])
|
||||
+secNav('p18','p20')+readButton('p19');
|
||||
wireReadBtn('p19');
|
||||
}
|
||||
|
||||
function build_p20(){
|
||||
document.getElementById('p20-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 20 · Химия 7</div><h2>Понятие о кислотах</h2>'
|
||||
+'<div class="ph-desc">Что такое кислоты и как их обнаруживают индикаторами.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">кислота</span><span class="ph-tag">индикатор</span></div></div>'
|
||||
+makeCard('theory','Состав кислот','§20','<div class="def-box"><b>Кислоты</b> — сложные вещества, в состав которых входят атомы <b>водорода</b> и <b>кислотный остаток</b>.</div>'
|
||||
+'<p>Примеры: соляная $\\text{HCl}$, серная $\\text{H}_2\\text{SO}_4$, азотная $\\text{HNO}_3$, угольная $\\text{H}_2\\text{CO}_3$. Число атомов водорода в кислоте равно валентности кислотного остатка.</p>')
|
||||
+makeCard('theory','Индикаторы','§20','<p><b>Индикаторы</b> — вещества, которые меняют свой цвет в присутствии кислоты. В кислоте <b>лакмус</b> становится красным, а <b>метилоранж</b> — розово-красным. Так кислоту можно обнаружить.</p>')
|
||||
+wgt('Индикаторы в кислоте','<div id="p20-ind"></div>')
|
||||
+wgt('Важнейшие кислоты и их остатки','<div id="p20-acids"></div>')
|
||||
+rememberBox(['Кислота = атомы водорода + кислотный остаток.','Лакмус в кислоте — красный, метилоранж — розово-красный.','Индикаторы помогают обнаружить кислоту.'])
|
||||
+qList(['Из чего состоят кислоты?','Как с помощью индикатора обнаружить кислоту?','Назови формулу и название двух кислот.'])
|
||||
+secNav('p19','lo3')+readButton('p20');
|
||||
wireReadBtn('p20');
|
||||
}
|
||||
|
||||
function build_lo3(){
|
||||
document.getElementById('lo3-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Лабораторный опыт 3</div><h2>Действие кислот на индикаторы</h2>'
|
||||
+'<div class="ph-desc">Научиться обнаруживать кислоту с помощью индикаторов.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>В пробирку с раствором кислоты добавь несколько капель <b>лакмуса</b> — он станет красным.</li><li>В другую пробирку с кислотой добавь <b>метилоранж</b> — он станет розово-красным.</li><li>Сравни с окраской индикаторов в чистой воде.</li><li>Сделай вывод, как обнаружить кислоту.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Кислоты едкие — работай аккуратно, не допускай попадания на кожу и одежду.</div>')
|
||||
+wgt('Индикаторы в кислоте','<div id="lo3-ind"></div>')
|
||||
+secNav('p20','p21')+readButton('lo3');
|
||||
wireReadBtn('lo3');
|
||||
}
|
||||
|
||||
function build_p21(){
|
||||
document.getElementById('p21-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 21 · Химия 7</div><h2>Взаимодействие кислот с металлами</h2>'
|
||||
+'<div class="ph-formula">Me $+$ кислота $\\to$ соль $+ \\text{H}_2\\uparrow$</div>'
|
||||
+'<div class="ph-desc">Почему одни металлы вытесняют водород из кислот, а другие нет.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">ряд активности</span><span class="ph-tag">$H_2\\uparrow$</span></div></div>'
|
||||
+makeCard('theory','Реакция металлов с кислотами','§21','<p>Многие металлы реагируют с кислотами, <b>вытесняя водород</b>: образуются соль и газообразный водород. Например: $\\text{Zn}+\\text{H}_2\\text{SO}_4=\\text{ZnSO}_4+\\text{H}_2\\uparrow$, $\\text{Mg}+2\\text{HCl}=\\text{MgCl}_2+\\text{H}_2\\uparrow$.</p>')
|
||||
+makeCard('rule','Ряд активности металлов','§21','<div class="def-box">Металлы расположены в <b>ряд активности</b>. Металлы, стоящие <b>левее водорода</b>, вытесняют его из соляной и серной кислот; стоящие <b>правее</b> (медь, серебро) — не вытесняют.</div>'
|
||||
+'<p>Очень активные металлы (натрий, калий) реагируют с кислотами слишком бурно — для получения водорода берут цинк или железо.</p>')
|
||||
+wgt('Интерактивный ряд активности','<div id="p21-act"></div>')
|
||||
+rememberBox(['Металл + кислота → соль + водород.','Вытесняют водород только металлы левее $H_2$.','Медь и серебро с этими кислотами не реагируют.'])
|
||||
+qList(['Что образуется при реакции металла с кислотой?','Почему медь не реагирует с соляной кислотой?','Запиши уравнение реакции цинка с серной кислотой.'])
|
||||
+secNav('p20','lo4')+readButton('p21');
|
||||
wireReadBtn('p21');
|
||||
}
|
||||
|
||||
function build_lo4(){
|
||||
document.getElementById('lo4-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Лабораторный опыт 4</div><h2>Взаимодействие серной и соляной кислот с металлами</h2>'
|
||||
+'<div class="ph-desc">Проверить, какие металлы вытесняют водород из кислот.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>В пробирки с соляной и серной кислотами помести кусочки цинка, железа, магния — наблюдай <b>выделение пузырьков</b> газа (водорода).</li><li>В пробирку с кислотой помести медь — изменений <b>нет</b>.</li><li>Объясни результаты, пользуясь рядом активности металлов.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Кислоты едкие; не наклоняйся над пробиркой, в которой идёт реакция.</div>')
|
||||
+wgt('Опыт: металл + кислота','<div id="lo4-rx"></div>')
|
||||
+secNav('p21','p22')+readButton('lo4');
|
||||
wireReadBtn('lo4');
|
||||
}
|
||||
|
||||
function build_p22(){
|
||||
document.getElementById('p22-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 22 · Химия 7</div><h2>Соли — продукты замещения атомов водорода в кислотах на металлы</h2>'
|
||||
+'<div class="ph-desc">Что такое соли и как составляют их формулы.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">соль</span><span class="ph-tag">замещение</span></div></div>'
|
||||
+makeCard('theory','Что такое соли','§22','<div class="def-box"><b>Соли</b> — сложные вещества, состоящие из атомов <b>металла</b> и <b>кислотного остатка</b>.</div>'
|
||||
+'<p>Соли образуются, когда металл <b>замещает</b> водород в кислоте — это <b>реакция замещения</b>: $\\text{Zn}+2\\text{HCl}=\\text{ZnCl}_2+\\text{H}_2$. Названия солей: хлориды ($\\text{NaCl}$), сульфаты ($\\text{Na}_2\\text{SO}_4$), нитраты ($\\text{KNO}_3$), карбонаты ($\\text{CaCO}_3$).</p>')
|
||||
+makeCard('example','Составление формулы соли',null,'<p>Кальций (II) и кислотный остаток $\\text{Cl}$ (I): НОК(2,1)=2 → $\\text{CaCl}_2$. Алюминий (III) и $\\text{SO}_4$ (II): НОК(3,2)=6 → $\\text{Al}_2(\\text{SO}_4)_3$.</p>')
|
||||
+wgt('Конструктор солей','<div id="p22-salt"></div>')
|
||||
+rememberBox(['Соль = металл + кислотный остаток.','Соль образуется при замещении водорода металлом.','Формулу соли составляют по валентности (НОК).'])
|
||||
+qList(['Дай определение соли.','Составь формулу хлорида магния.','Какая реакция называется реакцией замещения?'])
|
||||
+secNav('lo4','pr3')+readButton('p22');
|
||||
wireReadBtn('p22');
|
||||
}
|
||||
|
||||
function build_pr3(){
|
||||
document.getElementById('pr3-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Практическая работа 3</div><h2>Получение водорода и изучение его свойств</h2>'
|
||||
+'<div class="ph-desc">Получить водород, собрать его и проверить на чистоту.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>В пробирку с кусочками цинка прилей соляную кислоту — наблюдай выделение водорода: $\\text{Zn}+2\\text{HCl}=\\text{ZnCl}_2+\\text{H}_2\\uparrow$.</li><li>Собери водород (он легче воздуха — держи сосуд отверстием вниз, или собирай вытеснением воды).</li><li>Проверь чистоту: поднеси к отверстию пламя. Резкий <b>хлопок</b> — водород смешан с воздухом (нечисто); <b>спокойное</b> горение — чистый.</li><li>Сделай вывод о свойствах водорода.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Поджигай водород только после проверки чистоты; «гремучий газ» взрывоопасен.</div>')
|
||||
+wgt('Проверка чистоты водорода','<div id="pr3-test"></div>')
|
||||
+secNav('p22','final3')+readButton('pr3');
|
||||
wireReadBtn('pr3');
|
||||
}
|
||||
|
||||
function build_final3(){
|
||||
document.getElementById('final3-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Финал главы 3</div><h2>Босс: водород</h2>'
|
||||
+'<div class="ph-formula">$H_2$ · кислоты · ряд активности · соли</div>'
|
||||
+'<div class="ph-desc">Шесть задач на всю главу. Реши все — получи звание «Знаток водорода».</div></div>'
|
||||
+makeCard('rule','Шпаргалка главы 3',null,'<ul>'
|
||||
+'<li>$H$ — самый лёгкий элемент; $H_2$ — самый лёгкий газ.</li>'
|
||||
+'<li>Водород горит → вода; восстанавливает металлы из оксидов.</li>'
|
||||
+'<li>Кислота = водород + остаток; индикаторы (лакмус — красный в кислоте).</li>'
|
||||
+'<li>Металл + кислота → соль $+ H_2\\uparrow$ (только левее $H_2$ в ряду активности).</li>'
|
||||
+'<li>Соль = металл + кислотный остаток (продукт замещения).</li></ul>')
|
||||
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши задачи ниже — за каждую +5 XP, за полный разгром босса — звание и бонус.</p>'
|
||||
+secNav('pr3',null);
|
||||
}
|
||||
|
||||
/* заглушки для ещё не наполненных § (следующая волна) */
|
||||
(function(){
|
||||
var P = window.PARAS, B = {};
|
||||
function ph(p, prev, next){
|
||||
@@ -104,7 +315,7 @@ window.TIPS = [{ sec:'p18', html:'Глава наполняется содерж
|
||||
'<div class="para-hero"><div class="ph-label">' + p.num + ' · Химия 7</div><h2>' + p.name + '</h2>'
|
||||
+ '<div class="ph-desc">Содержание этого ' + (p.final ? 'раздела' : 'параграфа') + ' готовится.</div></div>'
|
||||
+ makeCard('theory', p.name, p.num,
|
||||
'<p>Скоро здесь появятся теория, наглядные SVG-схемы, молекулярные модели и интерактивные тренажёры по теме «' + p.name + '». Пока доступна навигация по главе' + (p.final ? '.' : ' и отметка о прочтении.') + '</p>')
|
||||
'<p>Скоро здесь появятся теория, наглядные SVG-схемы и интерактивные тренажёры по теме «' + p.name + '». Пока доступна навигация по главе' + (p.final ? '.' : ' и отметка о прочтении.') + '</p>')
|
||||
+ secNav(prev, next) + (p.final ? '' : readButton(p.id));
|
||||
if (!p.final) wireReadBtn(p.id);
|
||||
};
|
||||
@@ -114,6 +325,17 @@ window.TIPS = [{ sec:'p18', html:'Глава наполняется содерж
|
||||
}
|
||||
window.BUILDERS = B;
|
||||
})();
|
||||
|
||||
/* реальные builder'ы Волны 1 главы 3 */
|
||||
window.BUILDERS.p18 = build_p18;
|
||||
window.BUILDERS.p19 = build_p19;
|
||||
window.BUILDERS.p20 = build_p20;
|
||||
window.BUILDERS.lo3 = build_lo3;
|
||||
window.BUILDERS.p21 = build_p21;
|
||||
window.BUILDERS.lo4 = build_lo4;
|
||||
window.BUILDERS.p22 = build_p22;
|
||||
window.BUILDERS.pr3 = build_pr3;
|
||||
window.BUILDERS.final3 = build_final3;
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="/css/chem8-textbook.css">
|
||||
<style>/* Химия 7 · тема главы 4 — blue (переопределяет amber chem8-textbook.css) */
|
||||
:root{--bg:#eff6ff;--border:#bfdbfe;--pri:#2563eb;--pri-d:#1d4ed8;--pri-l:#60a5fa;--pri-soft:#dbeafe;--sec-acc:#2563eb;--sec-acc-d:#1d4ed8;--sec-acc-soft:#dbeafe;--warn:#1d4ed8;--warn-bg:#dbeafe}
|
||||
html.dark{--bg:#0a1222;--border:#1e3a5f;--pri-soft:rgba(37,99,235,.18);--sec-acc-soft:rgba(37,99,235,.18)}
|
||||
.hdr{background:linear-gradient(110deg,#1e3a8a 0%,#2563eb 55%,#60a5fa 100%)}
|
||||
.hero{background:linear-gradient(135deg,var(--pri-soft),rgba(96,165,250,.12))}
|
||||
.para-hero{background:linear-gradient(135deg,#1e3a8a,#2563eb 55%,#60a5fa)}</style>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="/js/api.js" defer></script>
|
||||
@@ -17,6 +23,8 @@
|
||||
<script src="/js/biochem-core.js" defer></script>
|
||||
<script src="/js/chem8_svg.js" defer></script>
|
||||
<script src="/js/chem7_svg.js" defer></script>
|
||||
<script src="/js/chem7_anim.js" defer></script>
|
||||
<script src="/js/chem7_ch4_widgets.js" defer></script>
|
||||
<script src="/js/chem8_engine.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -86,30 +94,196 @@ window.PARAS = [
|
||||
{id:'final4', num:'★', name:'Финал главы', sub:'босс · ачивка', final:true}
|
||||
];
|
||||
|
||||
window.ACH_LABELS = { start:'Начало главы 4!', final4_tasks:'Глава 4 пройдена!' };
|
||||
window.SIDEBARS = { p23:{ title:'Глава 4 · Химия 7', rows:[['Раздел','Вода'],['§§','23–26'],['Лаб/ПР','ЛО 5 · ПР 4']] } };
|
||||
window.TIPS = [{ sec:'p23', html:'Глава наполняется содержанием по фазам. Сейчас доступны навигация по параграфам и отметка о прочтении (+10 XP).' }];
|
||||
window.ACH_LABELS = { start:'Начало главы 4!', p23_done:'§23 изучен!', p24_done:'§24 изучен!',
|
||||
lo5_done:'Лабораторный опыт 5 выполнен!', p25_done:'§25 изучен!', pr4_done:'Практическая работа 4 выполнена!',
|
||||
p26_done:'§26 изучен!', final4_tasks:'Глава 4 пройдена! Вы — Хранитель воды!' };
|
||||
window.SIDEBARS = {
|
||||
p23:{ title:'Шпаргалка §23', rows:[['Вода','$H_2O$'],['Разложение','$H_2:O_2=2:1$'],['$t_{кип}$','100 °C']] },
|
||||
p24:{ title:'Шпаргалка §24', rows:[['Основание','Me + OH'],['Щёлочи','раствор.: NaOH, KOH'],['Фенолфталеин','в щёлочи малиновый']] },
|
||||
lo5:{ title:'Лаб. опыт 5', rows:[['Лакмус','синий'],['Фенолфталеин','малиновый']] },
|
||||
p25:{ title:'Шпаргалка §25', rows:[['Нейтрализация','кислота + основание'],['Продукты','соль + вода'],['Индикатор','показывает конец']] },
|
||||
pr4:{ title:'Практическая 4', rows:[['Щёлочь + фенолфталеин','малиновый'],['+ кислота','до бесцветного']] },
|
||||
p26:{ title:'Шпаргалка §26', rows:[['Загрязнение','стоки, выбросы'],['Очистка','фильтр, хлор, озон'],['Береги','воду и воздух']] },
|
||||
final4:{ title:'Финал главы 4', rows:[['§§23–26','вода'],['Награда','ачивка + XP']] }
|
||||
};
|
||||
window.TIPS = [
|
||||
{ sec:'p23', html:'Вода $H_2O$ разлагается электрическим током на простые вещества: водорода по объёму в <b>2 раза больше</b>, чем кислорода.' },
|
||||
{ sec:'p24', html:'<b>Основание</b> = металл + гидроксогруппа OH. Растворимые основания (NaOH, KOH, $Ca(OH)_2$) называют <b>щёлочами</b>. В щёлочи фенолфталеин — малиновый, лакмус — синий.' },
|
||||
{ sec:'lo5', html:'В щёлочи лакмус становится синим, а фенолфталеин — малиновым. Так обнаруживают щёлочь.' },
|
||||
{ sec:'p25', html:'<b>Нейтрализация</b>: кислота + основание → соль + вода. Конец реакции виден по изменению цвета индикатора.' },
|
||||
{ sec:'pr4', html:'К щёлочи с фенолфталеином (малиновый) добавляют кислоту по каплям, пока раствор не станет бесцветным.' },
|
||||
{ sec:'p26', html:'Воду и воздух нужно беречь: очищать стоки, фильтровать и обеззараживать воду, не загрязнять водоёмы.' }
|
||||
];
|
||||
|
||||
/* Phase 0: заглушки-builder'ы из PARAS (теория и интерактивы добавляются в фазах 1–4). */
|
||||
window.POOLS = {
|
||||
p23:[
|
||||
{q:'Какова формула воды?',opts:['HO','H₂O','H₂O₂','OH'],a:1,ex:'Вода — $H_2O$.'},
|
||||
{q:'При разложении воды объём водорода во сколько раз больше объёма кислорода?',hint:'2 : 1',unit:'раза',a:2,ex:'$H_2:O_2=2:1$.'},
|
||||
{q:'Что образуется при разложении воды электрическим током?',opts:['Водород и кислород','Только водород','Соль и вода','Углекислый газ'],a:0,ex:'$2H_2O=2H_2\\uparrow+O_2\\uparrow$.'},
|
||||
{q:'Чему равна температура кипения воды (°C)?',hint:'при нормальных условиях',unit:'°C',a:100,ex:'100 °C.'}
|
||||
],
|
||||
p24:[
|
||||
{q:'Из чего состоит основание?',opts:['Из металла и кислотного остатка','Из металла и гидроксогруппы OH','Из водорода и остатка','Из двух неметаллов'],a:1,ex:'Основание = металл + OH.'},
|
||||
{q:'Какого цвета фенолфталеин в щёлочи?',opts:['Бесцветный','Малиновый','Жёлтый','Синий'],a:1,ex:'В щёлочи фенолфталеин малиновый.'},
|
||||
{q:'Какова формула гидроксида натрия?',opts:['NaOH','Na₂O','NaCl','NaH'],a:0,ex:'Гидроксид натрия — $NaOH$.'},
|
||||
{q:'Растворимые в воде основания называют…',opts:['Кислотами','Щёлочами','Солями','Оксидами'],a:1,ex:'Растворимые основания — щёлочи.'}
|
||||
],
|
||||
p25:[
|
||||
{q:'Реакция кислоты с основанием называется реакцией…',opts:['Разложения','Нейтрализации','Замещения','Горения'],a:1,ex:'Кислота + основание → соль + вода — нейтрализация.'},
|
||||
{q:'Продукты реакции $\\text{HCl}+\\text{NaOH}$ — это…',opts:['Соль и вода','Два газа','Металл и вода','Оксид и вода'],a:0,ex:'$HCl+NaOH=NaCl+H_2O$.'},
|
||||
{q:'Сколько веществ образуется в реакции $\\text{HCl}+\\text{NaOH}=\\text{NaCl}+\\text{H}_2\\text{O}$?',hint:'соль и вода',unit:'',a:2,ex:'Соль и вода — 2 вещества.'},
|
||||
{q:'Как узнать, что реакция нейтрализации завершилась?',opts:['По выделению газа','По изменению цвета индикатора','По осадку','Никак'],a:1,ex:'Индикатор меняет цвет в конце реакции.'}
|
||||
],
|
||||
p26:[
|
||||
{q:'Что загрязняет воду?',opts:['Чистый дождь','Неочищенные сточные воды','Снег','Лёд'],a:1,ex:'Сточные воды и выбросы загрязняют водоёмы.'},
|
||||
{q:'Как очищают питьевую воду на водопроводных станциях?',opts:['Замораживанием','Фильтрованием и обработкой хлором или озоном','Кипячением навсегда','Никак'],a:1,ex:'Воду фильтруют и обеззараживают хлором или озоном.'},
|
||||
{q:'Из-за чего возникают кислотные дожди?',opts:['Из-за выбросов оксидов серы и азота','Из-за чистого воздуха','Из-за воды','Из-за песка'],a:0,ex:'Оксиды в воздухе образуют кислоты.'},
|
||||
{q:'Как беречь воду?',opts:['Лить без счёта','Экономить и не загрязнять','Сливать отходы в реку','Не пить'],a:1,ex:'Экономить воду и не загрязнять водоёмы.'}
|
||||
],
|
||||
final4:[
|
||||
{q:'Формула воды?',opts:['HO','H₂O','OH','H₂O₂'],a:1,ex:'$H_2O$.'},
|
||||
{q:'При разложении воды $H_2:O_2$ по объёму = ? : 1. Чему равно первое число?',hint:'2:1',unit:'',a:2,ex:'2.'},
|
||||
{q:'Основание состоит из…',opts:['Металла и OH','Водорода и остатка','Двух металлов','Металла и кислорода'],a:0,ex:'Металл + OH.'},
|
||||
{q:'Фенолфталеин в щёлочи становится…',opts:['Бесцветным','Малиновым','Красным','Жёлтым'],a:1,ex:'Малиновый.'},
|
||||
{q:'Продукты реакции нейтрализации — это…',opts:['Соль и вода','Два газа','Оксид и металл','Кислота и щёлочь'],a:0,ex:'Соль + вода.'},
|
||||
{q:'Как обеззараживают питьевую воду?',opts:['Хлорированием или озонированием','Заморозкой','Подкислением','Ничем'],a:0,ex:'Хлором или озоном.'}
|
||||
]
|
||||
};
|
||||
|
||||
function rememberBox(items){
|
||||
return '<div class="remember-box"><div class="remember-box-title">'
|
||||
+'<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'
|
||||
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>';
|
||||
}
|
||||
function qList(items){
|
||||
return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'
|
||||
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>';
|
||||
}
|
||||
function wgt(title, inner){
|
||||
return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>';
|
||||
}
|
||||
|
||||
function build_p23(){
|
||||
document.getElementById('p23-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 23 · Химия 7</div><h2>Состав, физические и химические свойства воды</h2>'
|
||||
+'<div class="ph-formula">$\\text{H}_2\\text{O}$</div>'
|
||||
+'<div class="ph-desc">Самое важное вещество на Земле: его состав и превращения.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">$H_2O$</span><span class="ph-tag">разложение</span></div></div>'
|
||||
+makeCard('theory','Состав и физические свойства','§23','<p>Вода — <b>сложное вещество</b> $\\text{H}_2\\text{O}$: молекула из двух атомов водорода и одного атома кислорода. Это жидкость без цвета, запаха и вкуса; замерзает при $0\\,^\\circ$C, кипит при $100\\,^\\circ$C.</p>')
|
||||
+makeCard('theory','Химические свойства','§23','<ul><li>Разлагается электрическим током: $2\\text{H}_2\\text{O}=2\\text{H}_2\\uparrow+\\text{O}_2\\uparrow$ (водорода вдвое больше).</li><li>Реагирует с активными металлами: $2\\text{Na}+2\\text{H}_2\\text{O}=2\\text{NaOH}+\\text{H}_2\\uparrow$.</li><li>С оксидами металлов даёт основания: $\\text{CaO}+\\text{H}_2\\text{O}=\\text{Ca(OH)}_2$.</li><li>С оксидами неметаллов даёт кислоты: $\\text{CO}_2+\\text{H}_2\\text{O}=\\text{H}_2\\text{CO}_3$.</li></ul>')
|
||||
+wgt('Разложение воды и реакции воды','<div id="p23-water"></div>')
|
||||
+rememberBox(['Вода $H_2O$ — сложное вещество.','При разложении $H_2:O_2=2:1$ по объёму.','Вода реагирует с активными металлами и с оксидами.'])
|
||||
+qList(['Каков состав молекулы воды?','Что образуется при разложении воды током?','Что получится при реакции $CO_2$ с водой?'])
|
||||
+secNav(null,'p24')+readButton('p23');
|
||||
wireReadBtn('p23');
|
||||
}
|
||||
|
||||
function build_p24(){
|
||||
document.getElementById('p24-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 24 · Химия 7</div><h2>Основания как сложные вещества</h2>'
|
||||
+'<div class="ph-formula">Me(OH)$_n$</div>'
|
||||
+'<div class="ph-desc">Что такое основания и как их обнаруживают индикаторами.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">основание</span><span class="ph-tag">щёлочь</span></div></div>'
|
||||
+makeCard('theory','Состав оснований','§24','<div class="def-box"><b>Основания</b> — сложные вещества, состоящие из атомов <b>металла</b> и одной или нескольких <b>гидроксогрупп</b> OH.</div>'
|
||||
+'<p>Примеры: $\\text{NaOH}$, $\\text{KOH}$, $\\text{Ca(OH)}_2$, $\\text{Cu(OH)}_2$. Растворимые в воде основания называют <b>щёлочами</b> (NaOH, KOH, $Ca(OH)_2$), остальные — нерастворимые. Название: «гидроксид» + металл.</p>')
|
||||
+makeCard('theory','Индикаторы в щёлочи','§24','<p>В щёлочи <b>лакмус</b> становится синим, <b>фенолфталеин</b> — малиновым, <b>метилоранж</b> — жёлтым. Так обнаруживают щёлочь.</p>')
|
||||
+wgt('Конструктор основания','<div id="p24-bld"></div>')
|
||||
+wgt('Индикаторы в щёлочи','<div id="p24-ind"></div>')
|
||||
+rememberBox(['Основание = металл + группа OH.','Растворимые основания — щёлочи.','Фенолфталеин в щёлочи малиновый, лакмус — синий.'])
|
||||
+qList(['Дай определение основания.','Составь формулу гидроксида кальция.','Как обнаружить щёлочь индикатором?'])
|
||||
+secNav('p23','lo5')+readButton('p24');
|
||||
wireReadBtn('p24');
|
||||
}
|
||||
|
||||
function build_lo5(){
|
||||
document.getElementById('lo5-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Лабораторный опыт 5</div><h2>Действие щелочей на индикаторы</h2>'
|
||||
+'<div class="ph-desc">Научиться обнаруживать щёлочь с помощью индикаторов.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>В пробирку с раствором щёлочи добавь <b>лакмус</b> — он станет синим.</li><li>В другую пробирку с щёлочью добавь <b>фенолфталеин</b> — он станет малиновым.</li><li>Сравни с окраской в чистой воде.</li><li>Сделай вывод, как обнаружить щёлочь.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Щёлочи едкие — работай аккуратно, не допускай попадания на кожу.</div>')
|
||||
+wgt('Индикаторы в щёлочи','<div id="lo5-ind"></div>')
|
||||
+secNav('p24','p25')+readButton('lo5');
|
||||
wireReadBtn('lo5');
|
||||
}
|
||||
|
||||
function build_p25(){
|
||||
document.getElementById('p25-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 25 · Химия 7</div><h2>Реакция нейтрализации</h2>'
|
||||
+'<div class="ph-formula">кислота + основание $\\to$ соль + вода</div>'
|
||||
+'<div class="ph-desc">Что происходит, когда смешивают кислоту и щёлочь.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">нейтрализация</span><span class="ph-tag">соль + вода</span></div></div>'
|
||||
+makeCard('theory','Нейтрализация','§25','<div class="def-box"><b>Реакция нейтрализации</b> — реакция между кислотой и основанием, в результате которой образуются <b>соль и вода</b>.</div>'
|
||||
+'<p>Например: $\\text{HCl}+\\text{NaOH}=\\text{NaCl}+\\text{H}_2\\text{O}$. За ходом реакции следят по индикатору: фенолфталеин в щёлочи малиновый, а когда вся щёлочь нейтрализована — раствор становится бесцветным.</p>')
|
||||
+wgt('Анимация нейтрализации','<div id="p25-neu"></div>')
|
||||
+rememberBox(['Кислота + основание → соль + вода.','Это реакция нейтрализации.','Конец реакции виден по изменению цвета индикатора.'])
|
||||
+qList(['Какая реакция называется нейтрализацией?','Что образуется при реакции $HCl$ с $NaOH$?','Зачем при нейтрализации используют индикатор?'])
|
||||
+secNav('lo5','pr4')+readButton('p25');
|
||||
wireReadBtn('p25');
|
||||
}
|
||||
|
||||
function build_pr4(){
|
||||
document.getElementById('pr4-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Практическая работа 4</div><h2>Реакция нейтрализации</h2>'
|
||||
+'<div class="ph-desc">Провести нейтрализацию щёлочи кислотой и зафиксировать её конец по индикатору.</div></div>'
|
||||
+makeCard('lab','Ход работы',null,'<ol><li>В стакан с раствором щёлочи добавь несколько капель <b>фенолфталеина</b> — раствор станет малиновым.</li><li>По каплям приливай кислоту, перемешивая, пока раствор не <b>обесцветится</b>.</li><li>Обесцвечивание означает, что щёлочь нейтрализована: $\\text{HCl}+\\text{NaOH}=\\text{NaCl}+\\text{H}_2\\text{O}$.</li><li>Сделай вывод о признаке конца реакции нейтрализации.</li></ol>'
|
||||
+'<div class="note-safe"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Кислоты и щёлочи едкие — добавляй кислоту по каплям, аккуратно.</div>')
|
||||
+wgt('Нейтрализация: добавь кислоту','<div id="pr4-neu"></div>')
|
||||
+secNav('p25','p26')+readButton('pr4');
|
||||
wireReadBtn('pr4');
|
||||
}
|
||||
|
||||
function build_p26(){
|
||||
document.getElementById('p26-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">§ 26 · Химия 7</div><h2>Охрана окружающей среды</h2>'
|
||||
+'<div class="ph-desc">Почему важно беречь воду и воздух и как их защищают.</div>'
|
||||
+'<div class="ph-tags"><span class="ph-tag">экология</span><span class="ph-tag">очистка воды</span></div></div>'
|
||||
+makeCard('theory','Загрязнение и охрана','§26','<p>Вода и воздух — главные природные богатства. Их загрязняют промышленные выбросы, неочищенные сточные воды, разливы нефти. Оксиды серы и азота в воздухе вызывают <b>кислотные дожди</b>.</p>'
|
||||
+'<div class="def-box">Чтобы беречь природу, сточные воды очищают на специальных сооружениях, питьевую воду фильтруют и обеззараживают хлором или озоном, а каждый человек должен экономить воду и не загрязнять водоёмы.</div>')
|
||||
+wgt('Источники загрязнения и способы охраны','<div id="p26-eco"></div>')
|
||||
+rememberBox(['Воду и воздух загрязняют выбросы и стоки.','Воду очищают фильтрованием и обеззараживанием.','Беречь природу — задача каждого человека.'])
|
||||
+qList(['Что загрязняет воду и воздух?','Как очищают питьевую воду?','Что может сделать каждый, чтобы беречь воду?'])
|
||||
+secNav('pr4','final4')+readButton('p26');
|
||||
wireReadBtn('p26');
|
||||
}
|
||||
|
||||
function build_final4(){
|
||||
document.getElementById('final4-body').innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">Финал главы 4</div><h2>Босс: вода</h2>'
|
||||
+'<div class="ph-formula">$H_2O$ · основания · нейтрализация · экология</div>'
|
||||
+'<div class="ph-desc">Шесть задач на всю главу. Реши все — получи звание «Хранитель воды».</div></div>'
|
||||
+makeCard('rule','Шпаргалка главы 4',null,'<ul>'
|
||||
+'<li>Вода $H_2O$ разлагается током: $H_2:O_2=2:1$; реагирует с металлами и оксидами.</li>'
|
||||
+'<li>Основание = металл + OH; щёлочи — растворимые основания.</li>'
|
||||
+'<li>Индикаторы в щёлочи: лакмус — синий, фенолфталеин — малиновый.</li>'
|
||||
+'<li>Нейтрализация: кислота + основание → соль + вода.</li>'
|
||||
+'<li>Воду и воздух надо беречь и очищать.</li></ul>')
|
||||
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши задачи ниже — за каждую +5 XP, за полный разгром босса — звание и бонус. А затем проверь себя в финале всего курса на главной странице!</p>'
|
||||
+secNav('p26',null);
|
||||
}
|
||||
|
||||
/* placeholder-страховка (на случай нерасставленных override) */
|
||||
(function(){
|
||||
var P = window.PARAS, B = {};
|
||||
function ph(p, prev, next){
|
||||
return function(){
|
||||
var el = document.getElementById(p.id + '-body'); if (!el) return;
|
||||
el.innerHTML =
|
||||
'<div class="para-hero"><div class="ph-label">' + p.num + ' · Химия 7</div><h2>' + p.name + '</h2>'
|
||||
+ '<div class="ph-desc">Содержание этого ' + (p.final ? 'раздела' : 'параграфа') + ' готовится.</div></div>'
|
||||
+ makeCard('theory', p.name, p.num,
|
||||
'<p>Скоро здесь появятся теория, наглядные SVG-схемы, молекулярные модели и интерактивные тренажёры по теме «' + p.name + '». Пока доступна навигация по главе' + (p.final ? '.' : ' и отметка о прочтении.') + '</p>')
|
||||
el.innerHTML = '<div class="para-hero"><div class="ph-label">' + p.num + ' · Химия 7</div><h2>' + p.name + '</h2></div>'
|
||||
+ makeCard('theory', p.name, p.num, '<p>Содержание готовится.</p>')
|
||||
+ secNav(prev, next) + (p.final ? '' : readButton(p.id));
|
||||
if (!p.final) wireReadBtn(p.id);
|
||||
};
|
||||
}
|
||||
for (var i = 0; i < P.length; i++) {
|
||||
B[P[i].id] = ph(P[i], i > 0 ? P[i-1].id : null, i < P.length-1 ? P[i+1].id : null);
|
||||
}
|
||||
for (var i = 0; i < P.length; i++) B[P[i].id] = ph(P[i], i > 0 ? P[i-1].id : null, i < P.length-1 ? P[i+1].id : null);
|
||||
window.BUILDERS = B;
|
||||
})();
|
||||
|
||||
window.BUILDERS.p23 = build_p23;
|
||||
window.BUILDERS.p24 = build_p24;
|
||||
window.BUILDERS.lo5 = build_lo5;
|
||||
window.BUILDERS.p25 = build_p25;
|
||||
window.BUILDERS.pr4 = build_pr4;
|
||||
window.BUILDERS.p26 = build_p26;
|
||||
window.BUILDERS.final4 = build_final4;
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -937,6 +937,7 @@ async function biochemGetElements() { return req('GET', '/biochem/elements'
|
||||
async function biochemGetMolecules(p={}) { return req('GET', `/biochem/molecules?${new URLSearchParams(p)}`); }
|
||||
async function biochemGetMolecule(id) { return req('GET', `/biochem/molecules/${id}`); }
|
||||
async function biochemValidate(atoms,bonds){ return req('POST','/biochem/validate',{atoms,bonds}); }
|
||||
async function biochemAnalyze(atoms,bonds){ return req('POST','/biochem/analyze',{atoms,bonds}); }
|
||||
async function biochemGetReactions() { return req('GET', '/biochem/reactions'); }
|
||||
async function biochemGetChallenges() { return req('GET', '/biochem/challenges'); }
|
||||
async function biochemSolveChallenge(id,payload) { return req('POST',`/biochem/challenges/${id}/solve`,payload); }
|
||||
@@ -1065,7 +1066,7 @@ window.LS = {
|
||||
clearFeaturesCache,
|
||||
hideDisabledFeatures,
|
||||
showBoardIfAllowed,
|
||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
|
||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
||||
biochemGetPathways, biochemGetPathwayProgress, biochemSavePathwayProgress,
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
${L('/library', 'book-open', 'Библиотека')}
|
||||
${L('/theory', 'brain', 'Теория')}
|
||||
${L('/knowledge-map', 'share-2', 'Карта знаний')}
|
||||
${L('/flashcards', 'copy', 'Флэшкарты')}
|
||||
${L('/question-bank', 'database', 'Банк вопросов', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
${L('/exam-prep/math9', 'clipboard-check', 'Подготовка к экзамену 9')}
|
||||
`)}
|
||||
@@ -191,4 +192,13 @@
|
||||
LS.hideDisabledFeatures?.();
|
||||
LS.notif?.init?.();
|
||||
}
|
||||
|
||||
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
|
||||
if (typeof LS !== 'undefined' && LS.isLoggedIn?.() && !document.getElementById('fc-fab-loader')) {
|
||||
const s = document.createElement('script');
|
||||
s.id = 'fc-fab-loader';
|
||||
s.src = '/js/flashcard-fab.js';
|
||||
s.defer = true;
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -74,14 +74,19 @@
|
||||
|
||||
Считать химию, а не хранить класс строкой.
|
||||
|
||||
- [ ] 2.1 `backend/src/services/chem.js`:
|
||||
- Полярность связей по разнице электроотрицательностей; **дипольный момент** молекулы (вектор-сумма с учётом 3D-геометрии из Фазы 1) → polar/nonpolar обоснованно.
|
||||
- Частичные заряды (упрощённый Gasteiger / EN-метод) для раскраски атомов.
|
||||
- DBE (степень ненасыщенности), молярная масса, массовые доли элементов.
|
||||
- Гибридизация центра, классификация функциональных групп через SMARTS-подобные паттерны (вынести из хардкода фронта).
|
||||
- [ ] 2.2 API `POST /api/biochem/analyze` (atoms,bonds → {formula, mass, dbe, dipole, polarity, charges, groups, hybridization}). Заменить фронтовую эвристику.
|
||||
- [ ] 2.3 В редакторе: тепловая карта частичных зарядов (toggle), стрелка диполя в 3D, панель «геометрия и полярность».
|
||||
- [ ] 2.4 Расширенная валидация: вместо «лимит превышен» — подсказки («у C занято 5 связей, максимум 4», «кислород обычно 2 связи»).
|
||||
> Серверный срез (тег `biochem-phase2-server`): `backend/src/services/chem.js`
|
||||
> **переиспользует то же ядро** `biochem-core.js` (сделан dual-export: браузер
|
||||
> `window.BIO` + Node `module.exports`) — без дублирования химии. Эндпоинт
|
||||
> `POST /api/biochem/analyze` отдаёт {formula, mass, dbe, geometry, polarity,
|
||||
> dipole, charges, groups, massFractions, valency}; `/validate` переведён на
|
||||
> ядро (плюс чинит баг формата связей b.o/order). 2.4: `BIO.valency` с
|
||||
> подсказками («Углерод (C): занято 5 связей, максимум 4 — убери 1»),
|
||||
> используется и в редакторе, и на сервере.
|
||||
|
||||
- [x] 2.1 `backend/src/services/chem.js`: переиспользует ядро `BIO` (полярность/диполь по 3D-VSEPR, частичные заряды, DBE/масса/массовые доли, гибридизация, функциональные группы) — без дубля логики на сервере.
|
||||
- [x] 2.2 API `POST /api/biochem/analyze` (atoms,bonds → {formula, mass, dbe, dipole, polarity, charges, groups, hybridization, valency}). Живой анализ в редакторе оставлен client-side (мгновенно); сервер — авторитетный расчёт + валидация на сохранении.
|
||||
- [x] 2.3 В редакторе: тепловая карта частичных зарядов (toggle), стрелка диполя в 3D, панель «геометрия и полярность».
|
||||
- [x] 2.4 Расширенная валидация: `BIO.valency` даёт подсказки («Углерод (C): занято 5 связей, максимум 4 — убери 1») вместо «лимит превышен»; единая логика в редакторе и на сервере.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# План: визуальный и интерактивный апгрейд учебника «Химия 7»
|
||||
|
||||
**Дата:** 2026-05-30
|
||||
**Контекст:** дополнение к [PLAN_CHEMISTRY_7.md](PLAN_CHEMISTRY_7.md). Базовый учебник реализован
|
||||
полностью (все 26 §, 4 главы, виджеты в `chem7_ch1..4_widgets.js`), но интерактивы сейчас
|
||||
в основном **статичные**: SVG-картинки, клик-раскрытие, `<select>`, цветные полоски.
|
||||
**Цель этого плана** — превратить ключевые виджеты во **флагманы**: красивые, анимированные,
|
||||
«живые» интерактивы с частицами, плавными переходами и геймификацией, которые запоминаются
|
||||
семикласснику. Аналог [PLAN_PHYSICS_9_ADVANCED_INTERACTIVES.md](../textbooks-9/PLAN_PHYSICS_9_ADVANCED_INTERACTIVES.md).
|
||||
|
||||
---
|
||||
|
||||
## Принцип «флагмана» (красиво + анимированно)
|
||||
|
||||
Не очередной `select`+текст, а:
|
||||
- **анимация в реальном времени** (`requestAnimationFrame`): частицы, пузырьки, пламя, капли;
|
||||
- **прямое управление** мышью/тапом (drag, клик, ползунок), отклик сразу;
|
||||
- **плавные переходы** (easing, морфинг цвета, появление/исчезновение);
|
||||
- **достоверная химия в движении** (пузырьки H₂, рост кристаллов, цвет осадка, объёмы 2:1);
|
||||
- **микро-геймификация**: подсветка успеха, конфетти на боссах, мини-цель в каждом флагмане;
|
||||
- **бережно к производительности и доступности** (пауза вне экрана, `prefers-reduced-motion`).
|
||||
|
||||
Принцип внедрения — **без переписывания**: флагманы монтируются в **уже существующие
|
||||
контейнеры** виджетов (`#p15-burn`, `#p23-water`, `#p9-bld`, …). Старый статичный код заменяется
|
||||
анимированным внутри тех же `mount_*`. Тесты (`chemistry7-page.test.js`) и движок не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## A. Общий движок `frontend/js/chem7_anim.js` (фундамент)
|
||||
|
||||
Один неймспейс `window.Chem7Anim` со всем, что нужно флагманам, чтобы не дублировать код:
|
||||
|
||||
```js
|
||||
// RAF-реестр с авто-очисткой при уходе с § (goTo) и паузой вне вьюпорта
|
||||
Chem7Anim.loop(canvasOrSvg, stepFn) -> handle{stop()} // RAF + IntersectionObserver пауза
|
||||
Chem7Anim.particles(host, opts) // система частиц (пузырьки, пар, искры, дым)
|
||||
Chem7Anim.bubbles(host, {rate, color, riseSpeed}) // поток пузырьков газа (H₂, O₂, CO₂)
|
||||
Chem7Anim.flame(host, {color, intensity, sparks}) // анимированное пламя (огонь/искры/дым)
|
||||
Chem7Anim.colorMorph(el, fromColor, toColor, ms) // плавная смена цвета (осадок, индикатор)
|
||||
Chem7Anim.dropletInto(host, {color, onLand}) // падающая капля + расплыв цвета
|
||||
Chem7Anim.grow(el, ms) / fadeSwap(el, htmlA, htmlB, ms) // появление/морфинг содержимого
|
||||
Chem7Anim.ease(t) // easeInOutCubic и пресеты
|
||||
Chem7Anim.confettiSmall(host) // лёгкое празднование (без CDN)
|
||||
Chem7Anim.reduced() // true при prefers-reduced-motion → упрощить
|
||||
```
|
||||
|
||||
- Один RAF-цикл на флагман; `handle.stop()` вызывается из cleanup при `goTo` (хук добавить в
|
||||
движок или дергать через `IntersectionObserver`, чтобы анимация на закрытой секции не жгла CPU).
|
||||
- Частицы — на `<canvas>` (производительно) либо SVG (≤30 элементов, легко стилизуется тёмной темой).
|
||||
- 3D-молекулы — **через `biochem-core.js`** (там уже есть шаростержневые 3D-модели), не дублировать.
|
||||
|
||||
---
|
||||
|
||||
## B. Визуальный стандарт «красиво»
|
||||
|
||||
1. **Палитра главы** уже задана (emerald/cyan/violet/blue) — анимации берут акцент главы
|
||||
(`var(--pri)`, `var(--pri-d)`, `var(--pri-soft)`), а не хардкод.
|
||||
2. **Достоверные цвета веществ**: пламя серы синее, магния — ослепительно-белое, искры железа —
|
||||
оранжевые; осадок Cu(OH)₂ голубой, медь красная, малахит зелёный→CuO чёрный; индикаторы
|
||||
(лакмус, метилоранж, фенолфталеин) — реальные оттенки.
|
||||
3. **Плавность**: все переходы 200–500 мс с easing; никаких резких скачков; hover-микроотклик
|
||||
(лёгкий подъём/тень) на кликабельных элементах.
|
||||
4. **Карточка-рамка** флагмана единая: заголовок, подсказка-цель, область анимации, контролы.
|
||||
5. **Тёмная тема**: все цвета через CSS-переменные/`html.dark`-ветки; canvas перечитывает фон.
|
||||
6. **Доступность**: при `prefers-reduced-motion` — статичный финальный кадр вместо цикла;
|
||||
`aria-label` на canvas; управление и с клавиатуры.
|
||||
7. **Без эмодзи** — только inline SVG `.ic` (правило проекта [[feedback_no_emoji]]).
|
||||
8. **Производительность**: пауза анимации, когда виджет не на экране (`IntersectionObserver`);
|
||||
`cancelAnimationFrame` при уходе с §; ≤1 RAF-цикл на видимый флагман.
|
||||
|
||||
---
|
||||
|
||||
## C. Флагманы по главам (текущий виджет → анимированный апгрейд)
|
||||
|
||||
### Глава 1 — Первоначальные понятия (emerald)
|
||||
| § | Контейнер | Сейчас | Флагман (анимация) |
|
||||
|---|---|---|---|
|
||||
| §2 / ПР1 | `#p2-sep` / `#pr1-sep` | выбор метода (текст) | **Симулятор разделения смесей**: частицы песка оседают на фильтре, вода капает; при выпаривании — пар уходит частицами, растут кристаллы соли; магнит притягивает железные опилки (полёт частиц). RAF + `particles` |
|
||||
| §5 / §6 | `#p5-gal` / `#p6-gal` | статичные шарики | **3D-модели молекул** (biochem-core): O₂/O₃/H₂/N₂/H₂O/CO₂/CH₄/NH₃ — вращение мышью, лёгкое «дыхание» атомов |
|
||||
| §9 | `#p9-bld` | селекты → формула | **Конструктор со «связями-крючками»**: у атомов выезжают чёрточки валентности; верное число — атомы с щелчком соединяются (snap + подсветка), неверное — отталкиваются (shake) |
|
||||
| §10 / ЛО1 | `#p10-signs` / `#lo1-signs` | список признаков | **Анимированные опыты-демо**: малахит зеленеет→чернеет (colorMorph) + пузырьки газа; CuSO₄+NaOH — падает синий осадок (частицы оседают); горение серы — синее пламя + «запах» (волны) |
|
||||
| §11 | `#p11-bal` | статичные весы (toggle) | **Живые весы массы**: коромысло качается и встаёт ровно; молекулы перелетают слева направо и перестраиваются в продукты; стрелка не сдвигается — «масса сохранилась» |
|
||||
| §12 | `#p12-mount` | балансировщик (Chem8) | **Живой балансировщик**: атомы-шарики каждого элемента слева/справа; при дисбалансе сторона «перевешивает» и мигает; ползунок коэффициента анимирует число молекул до баланса |
|
||||
|
||||
### Глава 2 — Кислород (cyan)
|
||||
| § | Контейнер | Сейчас | Флагман |
|
||||
|---|---|---|---|
|
||||
| §13 | `#p13-air` | статичная полоса | **Живая диаграмма воздуха**: донат «прорисовывается», внутри летают молекулы газов в пропорции 78/21/1; hover — сектор подсвечивается и всплывает |
|
||||
| §15 | `#p15-burn` | статичное пламя + ур-е | **Симулятор горения**: реальное пламя частицами (`flame`), цвет по веществу (S — синее, Mg — белое, Fe — оранжевые искры), дым; вещество «убывает», оксид оседает; уравнение проявляется |
|
||||
| §17 / ПР2 | `#p17-prod` / `#pr2-test` | схема (текст) | **Прибор получения O₂**: нагрев колбы (свечение), пузырьки поднимаются по трубке, газ вытесняет воду в сосуде; тлеющая лучинка **вспыхивает** (вспышка пламени) |
|
||||
|
||||
### Глава 3 — Водород (violet)
|
||||
| § | Контейнер | Сейчас | Флагман |
|
||||
|---|---|---|---|
|
||||
| §19 | `#p19-rx` | текст + свотч | **Восстановление CuO**: пробирка нагревается, поток H₂; чёрный CuO **плавно краснеет** до меди (colorMorph), на стенках конденсируются капли воды |
|
||||
| §20 / ЛО3 | `#p20-ind` / `#lo3-ind` | цветная полоска | **Капля индикатора**: капля падает в раствор и **цвет расплывается** радиально (лакмус→красный, метилоранж→розовый) |
|
||||
| §21 | `#p21-act` | кликабельный ряд | **Интерактивный ряд активности**: drag металла в стакан с кислотой → если левее H₂, идут **пузырьки H₂** (bubbles) и металл «тает»; правее — тишина; шкала «активности» подсвечивается градиентом |
|
||||
| ЛО4 | `#lo4-rx` | текст-результат | **Пробирка металл+кислота**: выбор металла/кислоты → анимация пузырьков (Zn/Fe/Mg) или их отсутствие (Cu); уравнение всплывает |
|
||||
| §22 | `#p22-salt` | селекты → формула | **Сборка соли**: атом металла «выталкивает» водород из кислоты (H₂ улетает пузырьком), кислотный остаток соединяется с металлом → формула соли собирается с анимацией |
|
||||
| ПР3 | `#pr3-test` | 2 кнопки | **Проверка чистоты H₂**: «хлопок» (вспышка + волна) для смеси с воздухом vs спокойное пламя для чистого |
|
||||
|
||||
### Глава 4 — Вода (blue)
|
||||
| § | Контейнер | Сейчас | Флагман |
|
||||
|---|---|---|---|
|
||||
| §23 | `#p23-water` | статичные пробирки 2:1 | **Электролиз воды**: на электродах рождаются пузырьки, трубка с H₂ наполняется **вдвое быстрее**, чем с O₂; счётчик объёмов растёт 2:1 в реальном времени |
|
||||
| §24 / ЛО5 | `#p24-ind` / `#lo5-ind` | цветная полоска | **Капля индикатора в щёлочи**: фенолфталеин расцветает **малиновым** (colorMorph), лакмус — синим |
|
||||
| §25 / ПР4 | `#p25-neu` / `#pr4-neu` | стакан (toggle) | **Титрование-нейтрализация**: капли кислоты падают в малиновый раствор, цвет **постепенно бледнеет** до бесцветного по мере добавления; «перелил» — предупреждение; уравнение всплывает |
|
||||
| §26 | `#p26-eco` | список-кнопки | **Круговорот и очистка воды**: анимированная инфографика — испарение→облака→дождь→река; на станции вода проходит фильтр и обеззараживание (поток капель по этапам) |
|
||||
|
||||
**Звёздные флагманы (вау-эффект):** §15 горение, §21 ряд активности с пузырьками, §23 электролиз
|
||||
2:1, §25 титрование, §5/§6 3D-молекулы, §2 разделение смесей.
|
||||
|
||||
---
|
||||
|
||||
## D. Порядок реализации (по фазам)
|
||||
|
||||
### Phase V0 — Движок `chem7_anim.js` (фундамент)
|
||||
- Создать `frontend/js/chem7_anim.js` (`window.Chem7Anim`): RAF-реестр + `IntersectionObserver`-пауза,
|
||||
система частиц на canvas, `bubbles`, `flame`, `colorMorph`, `dropletInto`, `confettiSmall`, `ease`, `reduced`.
|
||||
- CSS-хелперы (микро-переходы, hover-подъём, рамка флагмана) — добавить в общий
|
||||
`chem7-anim.css` (подключить в 4 главах) **или** инлайн-`<style>` в главах (как тема).
|
||||
- Подключить `chem7_anim.js` на 4 страницы глав (с `?v=YYYYMMDD` cache-busting).
|
||||
- Хук очистки: в `goTo`/unmount останавливать активные циклы (через `IntersectionObserver` на хосте
|
||||
виджета — не требует правок движка).
|
||||
- jsdom-тест: движок грузится, `loop/particles` создают canvas без ошибок, `stop()` чистит RAF.
|
||||
|
||||
### Phase V1 — Глава 1 (emerald): §2 смеси, §5/§6 3D-молекулы, §9 валентность, §10/§11/§12 реакции
|
||||
Пилот: §15-стиль не тут, но §5/§6 (3D через biochem-core) и §2 (частицы) — лучшие первые «вау».
|
||||
|
||||
### Phase V2 — Глава 2 (cyan): §13 диаграмма, §15 горение (звёздный), §17/ПР2 прибор O₂
|
||||
§15 — флагман главы; на нём отрабатываем `flame`/`particles`/«вспышку лучинки».
|
||||
|
||||
### Phase V3 — Глава 3 (violet): §19 восстановление, §20/ЛО3 индикаторы, §21 ряд активности (звёздный), ЛО4, §22 соли, ПР3
|
||||
§21 с drag-and-bubbles — флагман; переиспользуем `bubbles`/`colorMorph` из V2.
|
||||
|
||||
### Phase V4 — Глава 4 (blue): §23 электролиз (звёздный), §24/ЛО5 индикаторы, §25/ПР4 титрование (звёздный), §26 круговорот
|
||||
§23 (2:1) и §25 (титрование) — два флагмана; добиваем `dropletInto` и счётчики.
|
||||
|
||||
### Phase V5 — Полировка
|
||||
- Аудит: каждый RAF имеет пауза/`stop`, пауза вне экрана, корректный cleanup при `goTo`.
|
||||
- `prefers-reduced-motion`: статичный кадр; проверить тёмную тему на всех canvas.
|
||||
- Производительность: профиль на слабом устройстве; ≤1 активный цикл на видимый флагман.
|
||||
- Микро-геймификация: `confettiSmall` на победе боссов глав; подсветка успеха в флагманах.
|
||||
|
||||
> Темп: 2–3 флагмана за «волну», каждая = commit + проходящий тест (правило CLAUDE.md).
|
||||
> После каждой волны — **проверка персиста правок** через `node`-маркеры (флака кириллического
|
||||
> пути, см. [[project_chemistry7_textbook]] / [[feedback_verify_edits_applied]]) и push.
|
||||
|
||||
---
|
||||
|
||||
## E. Технологический стек
|
||||
|
||||
- **Canvas 2D** — частицы, пузырьки, пламя, дым (производительно, десятки-сотни частиц).
|
||||
- **SVG + CSS-анимации/transition** — лёгкие переходы, морфинг цвета, появление (≤30 элементов).
|
||||
- **biochem-core.js** — 3D-модели молекул (вращение) — НЕ дублировать.
|
||||
- **`Chem8.chemEq`** — рендер уравнений (всплывающие формулы) — переиспользовать.
|
||||
- **requestAnimationFrame** + RAF-реестр в `chem7_anim.js`; `IntersectionObserver` — пауза вне экрана.
|
||||
- **Web Audio (опционально)** — лёгкий «хлопок» гремучего газа, «пшик» лучинки (с тумблером звука, по умолчанию выкл).
|
||||
- **Без внешних CDN** для анимаций (свой `confettiSmall`); KaTeX/biochem-core уже подключены.
|
||||
|
||||
### Шаблон флагмана (внутри существующего `mount_*`)
|
||||
```js
|
||||
function mount_p15() {
|
||||
var host = $('p15-burn'); if (!host || host._built) return; host._built = 1;
|
||||
// ... контролы (select вещества + кнопка) ...
|
||||
var anim = null;
|
||||
function ignite(fuel){
|
||||
if (anim) anim.stop();
|
||||
anim = Chem7Anim.flame(host.querySelector('.stage'), { color: fuel.flame, sparks: fuel.el==='Fe' });
|
||||
// дым, убывание вещества, оседание оксида, всплытие уравнения...
|
||||
}
|
||||
// пауза вне экрана + stop при уходе:
|
||||
Chem7Anim.onHidden(host, function(){ if (anim) anim.stop(); });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. Критические правила
|
||||
|
||||
### ❌ НЕ делать
|
||||
- Бесконечный RAF без паузы/`stop` (жжёт CPU/батарею) — всегда `IntersectionObserver`-пауза + cleanup.
|
||||
- Хардкод цветов мимо палитры главы и тёмной темы.
|
||||
- Анимации без `prefers-reduced-motion`-ветки.
|
||||
- Дублировать 3D-движок молекул — только `biochem-core.js`.
|
||||
- Эмодзи — только inline SVG `.ic`.
|
||||
- Менять химию ради «красоты» (объёмы 2:1, достоверные цвета пламени/осадков/индикаторов).
|
||||
- Ломать тесты `chemistry7-page.test.js` (флагманы монтируются в те же контейнеры; ассерты на наличие узлов оставить рабочими).
|
||||
|
||||
### ✅ Обязательно
|
||||
- Один RAF-цикл на видимый флагман; `stop()` при уходе с § и пауза вне вьюпорта.
|
||||
- Достоверные цвета и пропорции (пламя S синее / Mg белое / Fe искры; H₂:O₂=2:1; осадки/индикаторы).
|
||||
- Тёмная тема и `prefers-reduced-motion` проверены на каждом флагмане.
|
||||
- После волны: jsdom-тест зелёный + **проверка персиста** правок `node`-маркером + push.
|
||||
- Cache-busting `?v=YYYYMMDD` на `chem7_anim.js` и обновлённых `chem7_chN_widgets.js`.
|
||||
|
||||
---
|
||||
|
||||
## G. Оценка объёма
|
||||
|
||||
| Блок | Что | LOC |
|
||||
|------|-----|-----|
|
||||
| V0 движок | `chem7_anim.js` (частицы, bubbles, flame, colorMorph, droplet, RAF-реестр, confetti) + CSS | ~1 200 |
|
||||
| V1 Гл.1 | смеси, 3D-молекулы, валентность-крючки, реакции/весы/балансировщик | ~1 400 |
|
||||
| V2 Гл.2 | диаграмма воздуха, горение (звёздный), прибор O₂ | ~1 100 |
|
||||
| V3 Гл.3 | восстановление, индикаторы-капля, ряд активности (звёздный), соли, ПР3 | ~1 400 |
|
||||
| V4 Гл.4 | электролиз 2:1 (звёздный), индикаторы, титрование (звёздный), круговорот | ~1 300 |
|
||||
| V5 полировка | reduced-motion, тёмная тема, перф, конфетти, аудит | ~400 |
|
||||
| **Итого** | **~15 флагманов + движок** | **~6 800 LOC** |
|
||||
|
||||
Прирост идёт **поверх** готового учебника: меняются тела `mount_*` (статика → анимация), добавляется
|
||||
один общий движок. Базовая логика (POOLS, теория, прогресс, KaTeX) не трогается.
|
||||
|
||||
---
|
||||
|
||||
## H. Связанные документы
|
||||
- [PLAN_CHEMISTRY_7.md](PLAN_CHEMISTRY_7.md) — базовый учебник (реализован).
|
||||
- [PLAN_PHYSICS_9_ADVANCED_INTERACTIVES.md](../textbooks-9/PLAN_PHYSICS_9_ADVANCED_INTERACTIVES.md) — образец «флагманского» подхода (RAF, геймификация, ленивая загрузка).
|
||||
- [[project_chemistry7_textbook]] — статус курса, контейнеры виджетов, флака кириллического пути.
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Запуск
|
||||
**Phase V0**: `chem7_anim.js` (движок частиц/RAF) + подключение на 4 страницы + jsdom-тест каркаса.
|
||||
**Phase V1**: пилот на Главе 1 — §5/§6 3D-молекулы (biochem-core) и §2 разделение смесей (частицы):
|
||||
самые быстрые «вау» с минимальным риском. Если заходит — катим V2→V4 по звёздным флагманам
|
||||
(§15 горение, §21 ряд активности, §23 электролиз, §25 титрование).
|
||||
Reference in New Issue
Block a user