51 Commits

Author SHA1 Message Date
Maxim Dolgolyov 4c1ce8394c feat(trigcircle): шкала значений по оси Y на графике (координатная плоскость)
- y-ось графика теперь подписана значениями (KaTeX):
  sin/cos — 1, ½, 0, −½, −1; tg/ctg — 3, 2, 1, 0, −1, −2, −3
- пунктирные линии уровней + подписи слева от панели
- подписи прячутся при смене функции (лишние уровни tg/ctg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:44:29 +03:00
Maxim Dolgolyov 0640efc82c feat(trigcircle): развёртка угла на графике + KaTeX-подписи граф-вида
- развёртка: участок кривой [0, α] выделяется ярче (с свечением) —
  видно, как угол на окружности «разворачивается» в график
- подпись текущего угла (π/3 и т.п.) на вертикальном маркере, KaTeX
- подписи делений оси X (π/2, π, 3π/2, 2π) — теперь KaTeX-оверлеем
- название функции (y = sin x / cos x / tg x / ctg x) — KaTeX-оверлеем
- _ovLabel: любая LaTeX-команда (\pi, \sin…) теперь рендерится через KaTeX

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:35:28 +03:00
Maxim Dolgolyov 7562d1a77b fix(trigcircle): координатная подпись больше не перекрывает угол
Координатный тултип (cos; sin) выносится радиально НАРУЖУ за точку (вдоль луча от центра),
а не просто со смещением — так KaTeX-плашка значений всегда дальше от центральной дуги угла
и её подписи (π/3 и т.п.), наложения нет.

Verified: node --check; смоук — coord-подпись дальше от центра, чем сама точка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:08:09 +03:00
Maxim Dolgolyov 1707a510a9 feat(trigcircle): KaTeX-оверлей для подписей на canvas (координаты, значения, угол)
На <canvas> KaTeX не рисуется (fillText), поэтому подписи, которые были юникод-текстом
(√2/2, координаты точки, π/4, значение на графике), переведены на HTML-оверлей #trig-overlay
поверх холста с KaTeX-рендером и точным позиционированием (transform по CSS-px = координаты
canvas). Переведены: координатная подсказка (cos; sin), бейджи значений sin/cos, метка угла
у дуги, бейдж значения на графике. Подписи-слова sin/cos/tg/ctg и мелкие точки табличных
углов остаются на canvas (не математика / 16 мелких меток).

Механика: _ov/_ovLabel/_ovClearUnused — кэш по ключу (ре-рендер только при смене LaTeX),
KaTeX лишь для дробей/корней, простые числа — текстом (быстро при перетаскивании), неис-
пользованные за кадр подписи прячутся. Старые canvas-методы _badge/_tooltip больше не зовутся.

Verified: node --check; headless-смоук оверлея 12/12 (coord/vsin/vcos/angle/gval создаются,
KaTeX-LaTeX для √2/2 и π/4, позиционирование/плашка, десятичные как текст, скрытие при
выкл. слоя/графика). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:03:39 +03:00
Maxim Dolgolyov e70bf819ce docs(trigcircle): статус — Ф1–Ф6 + KaTeX-везде готовы (Ф3 уже был)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:52:44 +03:00
Maxim Dolgolyov 48158ea88d feat(trigcircle): Фаза 5 — чётность/нечётность (−α) + периоды
Тумблер «Чётность (−α)»: на окружности рисуется зеркальная точка −α (отражение через
ось Ox, пунктир P↔−α) — наглядно нечётность sin и чётность cos. Блок-справка на KaTeX
(строится один раз): sin(−α)=−sin α, cos(−α)=cos α, tg(−α)=−tg α, периоды
T_sin=T_cos=2π, T_tg=T_ctg=π. (Формулы приведения для текущего угла — уже Фаза 2.)

Аддитивно: this.showParity + _drawParity + хук в draw(); glue trigToggleParity;
тумблер + #trig-parity в панели.

Verified: node --check; headless-смоук 9/9 (_drawParity без throw для 30/150/210/300;
toggle строит блок один раз с верными тождествами+периодами, показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:51:55 +03:00
Maxim Dolgolyov fe6df8fb98 feat(trigcircle): Фаза 4 — таблица значений (особые углы, KaTeX)
Тумблер «Таблица значений» → компактная таблица первой четверти (0/30/45/60/90°)
с точными sin/cos/tg/ctg на KaTeX (строится один раз). Строка опорного острого угла
текущего положения подсвечивается (150° → подсвечена строка 30°). По симметрии/приведению
(Фаза 2) это покрывает все 16 углов.

Glue: _trigBuildValueTable (один раз) + trigToggleTable; подсветка строки в _trigUpdateUI
по refDeg. Панель: тумблер + #trig-table.

Verified: node --check; headless-смоук 10/10 (5 строк, заголовки, значения 30/45/90,
tg «не опр.», toggle показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:49:33 +03:00
Maxim Dolgolyov 244df71aec feat(trigcircle): вся математика панели на KaTeX (значения + угол)
Значения sin/cos/tg/ctg в панели и стат-баре теперь рендерятся KaTeX для дробей/корней
(\tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …), а простые числа (0, ±1, десятичные) — текстом
(быстро при перетаскивании, без лишних KaTeX-вызовов). Бейдж угла — KaTeX π-доли по
таблице 16 углов (150° = 5π/6, 210° = 7π/6, …) + радианы + котерминальные.

Хелперы: _tex (общий рендер с фолбэком), _angleLatex/_piLabelToLatex (рад → LaTeX π-доли),
setMathVal (KaTeX только для нетривиальных форм). Формулы значений/приведения и уравнений
уже были на KaTeX.

Verified: node --check; headless-смоук 9/10 (10-я — артефакт стаба: в реальном DOM
textContent= очищает прежний innerHTML; логика LaTeX верна). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:47:30 +03:00
Maxim Dolgolyov 5bb0aeb940 docs(trigcircle): отметка статуса фаз (Ф1/Ф2/KaTeX/Ф6 готовы)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:37 +03:00
Maxim Dolgolyov dfa0535b63 feat(trigcircle): Фаза 6 — простейшие тригонометрические уравнения
Режим уравнения fn(x)=a (sin/cos/tg): окружность подсвечивает ВСЕ решения на [0,2π)
(точки + направляющая линия значения), а панель показывает общую формулу через KaTeX:
  sin x=a → x=(-1)ⁿ·arcsin a + πn;  cos x=a → x=±arccos a + 2πn;  tg x=a → x=arctg a + πn.
Для табличных значений главное значение подставляется точно (arcsin½=π/6 и т.п.), для
нетабличных — символьно (\arcsin a). |a|>1 для sin/cos → «нет решений». Список решений
в градусах. setEquation встаёт на первое решение; clearEquation выходит из режима.

Аддитивно: новое поле this.eq + методы setEquation/clearEquation/_drawEquation + хук в draw();
glue trigSetEqFn/trigSolve/trigClearEq/trigEqKey; секция «Уравнение» в панели labs-bodies.

Verified: node --check; headless-смоук 13/13 (решения sin/cos/tg/один/нет; формулы
(-1)ⁿ/±/+πn/none/нетабличное→arcsin) + изолированная отрисовка _drawEquation без throw.
Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:07 +03:00
Maxim Dolgolyov cefb5e0836 feat(trigcircle): рендер формул через KaTeX
Блок «Точные значения · приведение» теперь рендерится KaTeX (katex.renderToString,
как в graph.js/_sim_engine), с фолбэком на сырой LaTeX если katex ещё не загрузился.
Добавлен _latexVal (точное значение → LaTeX: \tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …),
функции \sin/\cos/\operatorname{tg}/\operatorname{ctg}, цвет наследуется от CSS-цвета
контейнера (без \textcolor). Формула приведения и значения — те же (проверено).

Verified: node --check; headless-смоук LaTeX-вывода (дамп + проверки) — 150°=180°−30°
sin=½ cos=−√3/2 tg=−√3/3; 45° без приведения √2/2; 90° tg «не опр.»; 210°=180°+30°;
300°=360°−60°; 137° нетабличный. Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:35:25 +03:00
Maxim Dolgolyov 5eed248702 feat(trigcircle): Фаза 2 — точные значения + формулы приведения
При исследовании выяснилось: Пифагор (sin²+cos²=1, _pythBar) и знаки по четвертям
(_quadSigns) уже рисуются на canvas. Поэтому Фаза 2 даёт главное недостающее по программе —
блок «Точные значения · приведение»: для текущего угла показывает sin/cos/tg/ctg точными
значениями (½, √2/2, √3/2, √3/3, √3) и для нетривиальных четвертей — формулу приведения
к острому углу (напр. 150° = 180°−30°, cos 150° = −cos 30° = −√3/2). Нетабличный угол →
сообщение. Без KaTeX (чистый HTML + готовый форматтер _f), без новых зависимостей.

Verified: node --check; headless-смоук рендера 11/11 (150° приведение+знаки, 45° QI без
головы, 210° QIII tg+, 137° нетабличный). Эмодзи нет.

sec/csc (5-я/6-я функции) — вторичны для школьной программы, отложены (предложу опционально).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:30:02 +03:00
Maxim Dolgolyov d395e1083b feat(trigcircle): Фаза 1 — работа с углами + обзор (тренажёр тригонометрии)
План тренажёра в plans/trig-circle/PLAN.md (всё по теме на окружности, кроме графиков функций).
Фаза 1 (аддитивно к рабочему режиму):
- Ввод угла в градусах (поле + Enter/кнопка) → goToAngle (нормализует, показывает
  котерминальность). Подсказка «+360°·k» в бейдже угла.
- Тумблер «График/функции» — скрыть график (тема «функции») → круг на всю ширину
  (переиспользует существующий слой graph + _layout).
- Полная сетка табличных углов (16: 0…330°) вместо 8.
- Опорный (острый) угол к оси Ox в выводе (основа формул приведения) + знаки sin/cos/tg
  по текущей четверти. stats() расширен полями refAngle/refDeg.

Verified: node --check; headless-смоук (vm + canvas-Proxy) 9/9 — опорный угол 30/150/210→30°,
300→60°, 90→90°, 0→0; знаки по четвертям (II: sin+ cos− tg−; IV: sin− cos+); новые
глобальные glue-функции определены. Эмодзи нет (стрелка — inline SVG .ic, tg-неопр. — em-dash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:23:31 +03:00
Maxim Dolgolyov 40df8893cc fix(lab): значок «связанной симуляции» на карточках учебников не скрывался при выключенной лаборатории
В каталоге учебников (textbooks.html) у карточек есть кнопка .tb-lab-btn «открыть
связанную симуляцию» (openLabSim → /lab?sim=…). Это <button onclick>, а не <a href="/lab">,
поэтому kill-switch `[href="/lab"]` её не ловил, и значок-колба оставался при отключённой
«Лаборатории».

Фикс: добавил `.tb-lab-btn` в FEATURE_WIDGETS.lab → api.js скрывает её через инъекцию
при lab=false (работает и без ls.css). Плюс страховка в openLabSim: при lab=false не
открываем (тост «Лаборатория отключена»); админ — всегда (admin-override).

Verified vm-смоук на реальном api.js 4/4 (lab off → .tb-lab-btn скрыта; lab on → нет;
admin → ничего). node --check api.js + инлайн textbooks.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:36:11 +03:00
Maxim Dolgolyov 8027d9fda0 fix(gamification): kill-switch не доходил до учебников (нет ls.css)
Учебники (frontend/textbooks/*.html, 112 шт.) грузят api.js, но НЕ ls.css. api.js ставил
класс .no-gamification на <html>, но сами правила kill-switch (`[data-gamified]`, попап XP)
живут в ls.css → до учебников не доходили, и встроенная XP-механика (бейдж/карточка XP,
ачивки, level-up попап #ach-popup) продолжала отображаться при выключенной геймификации.

Фикс — централизованно в _applyFeatureCss: при gamification=false дублируем правила
`.no-gamification [data-gamified],#ach-popup{display:none}` в инъектируемый <style>, поэтому
kill-switch работает на ЛЮБОЙ странице с api.js, без ls.css. Плюс на страницах без сайдбара
(учебники/embed) теперь авторитетно дёргаем loadFeatures() (только для залогиненных,
in-memory-дедуп) — кэш фич там не обновлялся. Админ по-прежнему видит всё (admin-override).

Verified vm-смоук на реальном api.js 7/7 (student+off → правило инъектится; student+on → нет;
admin → ничего).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:25:45 +03:00
Maxim Dolgolyov 43df41287f feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»
Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:17:04 +03:00
Maxim Dolgolyov db1db68488 fix(wishes): TypeError в toggleForm — lucide заменял <i> на <svg>
Кнопка «Поделиться идеей» падала: btn.querySelector('i') возвращал null, т.к. lucide.createIcons
при первом рендере заменяет <i data-lucide> на <svg>. Обернул иконку в стабильный
контейнер #wq-new-ic и пере-вставляю свежий <i> в его innerHTML перед icons() (с guard).

Headless-смоук toggleForm 5/5 (open/close, смена иконки chevron-up/plus, без throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:02:54 +03:00
Maxim Dolgolyov 3c45c606bf feat(admin/tests): пикер вопросов — серверный поиск по всему банку + «Показать ещё» + фильтры
Раньше «Добавить вопросы» в конструкторе тестов грузил лишь первые 100 вопросов предмета
(дефолтный лимит API), а поиск фильтровал клиентски только эти 100 — для математики
(1753 вопроса) 1653 были недоступны и не находились.

Теперь пикер ходит на сервер (бэкенд уже умеет q/difficulty/type/page): поле поиска
(debounce 300мс) ищет по ВСЕМУ банку предмета; кнопка «Показать ещё» подгружает
страницами по 100 с индикатором «Показано N из total»; добавлены фильтры по сложности
и типу. Поиск/фильтры сохраняются между перерисовками (после добавления вопроса).

Чистый фронтенд (tests.js + CSS в admin.html); бэкенд не тронут. Verified:
backend list q/difficulty/type/paging 8/8; headless-смоук пикера 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:17:17 +03:00
Maxim Dolgolyov 1649d6c2ec fix(admin): список сессий показывает и незавершённые (зависшие), +фильтр ?status
Корень бага «зависший тест есть в алерте, но нет в списке»: getAllSessions жёстко
фильтровал WHERE status='completed' → in_progress (зависшие) сессии в списке /admin#sessions
не появлялись. Теперь по умолчанию показываются все статусы (UI sessions.js уже null-safe:
percent/score/duration рисуются как «—»), плюс опциональный ?status=completed|in_progress|abandoned
для сужения. Зависшая сессия теперь находится и в списке, и открывается по deep-link из алерта.

admin-sessions.test.js 4/4; node --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:14:56 +03:00
Maxim Dolgolyov 4b5be8442b fix(admin): «Открыть» зависшей сессии ведёт на её детали, а не в пустой список
Алерт «Зависла» (in_progress >1ч) вёл на /admin#sessions, но список сессий показывает
ТОЛЬКО completed (getAllSessions: WHERE status='completed') — поэтому зависшей сессии там
не было (симптом: «показывает зависший тест, но в списке его нет»). Теперь «Открыть»
делает deep-link на детали конкретной сессии /admin#sessions/<id> — страница деталей
открывает сессию при любом статусе (getSessionDetail без фильтра по статусу) и позволяет
её посмотреть и удалить.

node --check OK; id присутствует в payload overview (stuckSessions → ts.id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:02:53 +03:00
Maxim Dolgolyov 3898080f04 fix(features): админ открывает отключённые модули — пейдж-гейты уважают admin-override
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html
свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от
роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал).

Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403):
collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа
(админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу
admin-override. Поведение для ученика/учителя не изменилось.

node --check инлайна всех 4 страниц — OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:59:51 +03:00
Maxim Dolgolyov efba722977 feat(wishes): редизайн страницы — удобнее и красивее
Полный фронт-редизайн /wishes (бэкенд не тронут):
- Hero с градиентной иконкой; «Поделиться идеей» — сворачиваемая форма (по умолчанию
  свёрнута, если пожелания уже есть; список сразу виден).
- Визуальный выбор категории чипами с иконками/цветом вместо select; счётчик символов.
- Статус-пилюли вверху с counts — кликабельный фильтр (для всех ролей, не только админ).
- Подбар: фильтр по категориям + живой поиск (по заголовку/тексту/автору); адаптивно
  скрывается, когда мало данных.
- Карточки: цветная иконка категории, статус-бейдж с иконкой, ответ админа в выделенном
  блоке, анимация появления, hover. Дружелюбные empty-состояния (нет идей / ничего не найдено)
  и скелетоны при загрузке.
- Клиентская фильтрация (один fetch, мгновенно) + точечные обновления списка без перезагрузки
  после создания/сохранения/удаления.

Verified: рендер-смоук 13/13 (карточки, иконки категорий, статусы, ответ, фильтры
status/cat/поиск с тогглом, empty); node --check инлайна; эмодзи нет (иконки — lucide).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:26:32 +03:00
Maxim Dolgolyov be9fdfa703 feat(wishes): трекер пожеланий по улучшению системы
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:12:10 +03:00
Maxim Dolgolyov 758e1bf6cb feat(dashboard): статус «идёт онлайн-урок» с присоединением
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:14 +03:00
Maxim Dolgolyov 0d4c658d93 refactor(assignments): единый модуль assignment-utils.js (тип/«сдано»/срочность)
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах
(dashboard.html, homework.html, assignmentController.js) и начала расходиться. Вынес в
один UMD-модуль frontend/js/assignment-utils.js (грузится и в браузере, и в Node через
require — как svg-sanitize.js): type(a), isDone(a, sub, opts), urgencyScore(a).

Нюанс «сдано» для upload/file параметризован: вид ученика (acceptedOnly) — закрыто только
при принятой сдаче; учитель/обзор долгов — любая сдача не на доработке. Поведение всех трёх
поверхностей сохранено 1:1.

- homework.html: asgnType/asgnDone/urgencyScore → тонкие делегаты в AssignmentUtils.
- dashboard.html: urgencyScore делегирует; classify и upload-ветка buildAssignCard через
  AssignmentUtils.type/isDone (заодно корректнее: учебник-ДЗ больше не путается с upload).
- assignmentController: classOutstanding/_assignTypeOf → AssignmentUtils.

Verified: AU-контракт 25/25 (типы, isDone teacher vs acceptedOnly, порядок urgency);
интеграция 8/8 (classOutstanding те же 14 уч./42 просрочено; homework делегирует). node --check
всех файлов + инлайна обоих HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:09:34 +03:00
Maxim Dolgolyov 5a4bc48027 feat(classes): вкладка «Долги» — что висит у учеников + удаление ДЗ класса/ученика
Новый read-only эндпоинт GET /api/classes/:id/outstanding (teacher/admin, ownership):
по каждому ученику класса — его незакрытые задания (классовые + личные от этого учителя)
со статусом не начато / в процессе / на доработке / просрочено и дедлайном. Логика «сдано»
совпадает с /homework (тест — завершён/исчерпаны попытки; учебник — всё прочитано;
загрузка/файл — есть сдача не на доработке). Общий запрос вынесен в assignmentRowsForUser(uid)
— им же теперь питается /assignments/my (поведение не изменилось, +поле created_by).

Фронт (classes.html): вкладка «Долги» в карточке класса — сводка «должников X из Y,
просрочено Z», по каждому должнику карточка со статус-чипами и списком зависших заданий;
бейдж с числом просрочек на вкладке. Удаление прямо из списка: личное → «у ученика»,
классовое → «у всего класса» (через DELETE /assignments/:id, ownership на бэке).

Verified: classOutstanding смоук на живой БД (14 учеников/58 позиций/42 просрочено,
ownership 403/404/admin); node --check; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:46:45 +03:00
Maxim Dolgolyov 73ba5a3530 fix(homework): блок ДЗ — только задания с флагом is_homework; ясная подпись типа
(1) На /homework блок «Актуальные задания» и выпадашка загрузки теперь показывают
только задания с флагом ДЗ (is_homework). Обычные тесты/экзамены и не-ДЗ файлы сюда
не попадают. Выпадашка привязки — только типы upload/file (куда ученик реально сдаёт
файл), без тест-ДЗ и учебников.

(2) В конструкторе задания (classes.html) вкладка типа «Сдать работу» → «Загрузка
работы»: остальные вкладки — существительные-типы контента (Файл, Готовый тест), а
«Сдать работу» читалась как действие ученика. Это тип upload — ДЗ, куда ученик грузит
файл (saveAssignment: is_homework=1, count=1); сам тип нужен, поправлена только подпись.

Headless-смоук фильтра ДЗ 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:08:57 +03:00
Maxim Dolgolyov a7f2ae9937 fix(features): админ видит и открывает все модули, даже отключённые
Скрытие фич в сайдбаре (и kill-switch геймификации) применялось независимо от роли —
у админа тоже пропадали пункты отключённых модулей. Админ управляет модулями, поэтому
должен видеть и открывать всё.

Добавлен admin-override (_isAdminUser, читает getUser() синхронно из localStorage —
работает и на ранней FOUC-инъекции): для админа _applyFeatureCss чистит <style>-скрытие
и снимает .no-gamification; hideDisabledFeatures выходит до любых скрытий/редиректов;
showBoardIfAllowed показывает доску админу всегда. Поведение для student/teacher не
изменилось. Verified vm-смоук на реальном api.js 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:03:09 +03:00
Maxim Dolgolyov 748b0aaab1 feat(homework): блок «Актуальные задания» на странице /homework
Раньше страница «Домашние задания» показывала только историю сдач, а сам список
актуальных ДЗ (что нужно сделать, с дедлайнами) жил лишь на дашборде. Теперь у ученика
сверху секция «Актуальные задания» на тех же данных (LS.myAssignments) — карточки с
дедлайном/просрочкой/срочностью, действие по типу задания: тест → Начать/Продолжить
(LS.startAssignment), учебник → Открыть §, файл → Скачать, ДЗ-загрузка → Сдать
(прокрутка к области загрузки + преднабор задания). Закрытые задания (пройденный тест,
прочитанный учебник, принятая работа) скрыты; пустая секция не показывается.

Заодно убрано ограничение «только первый класс» в выпадашке загрузки: задания берутся
по всем классам, а класс для отправки выводится из выбранного задания (data-class) —
чинит сдачу для учеников в нескольких классах.

Чисто фронтенд, аддитивно. Headless-смоук рендера 19/19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:57:50 +03:00
Maxim Dolgolyov 22c7b38e9a feat(admin): сброс системы «чистый запуск» в веб-панели
Добавлено такое же действие, как [Z] в control-panel: POST /api/admin/reset-system
(+ /reset-system/plan для предпросмотра), только admin. Общая логика вынесена в
src/services/systemReset.js (classify/pickKeptAdmin/runReset) — реюзится CLI и эндпоинтом.

Веб-эндпоинт безопаснее CLI: сохраняет ТЕКУЩЕГО админа (оператор остаётся залогинен),
делает бэкап БД ДО сброса (wal_checkpoint + копия в data/backups/), требует body.confirm='СБРОС'.
UI — «Опасная зона» в overview-секции: предпросмотр плана + ввод «СБРОС» + результат с именем бэкапа.

db.js: добавлен db._path (нужен бэкапу при сбросе). Логика проверена смоуком на копии живой БД
(16 юзеров удалено, контент сохранён, REASSIGN на админа, гейм-счётчики обнулены, 0 висячих FK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:45:13 +03:00
Maxim Dolgolyov 205290139d feat(control-panel): сброс системы «чистый запуск» (с бэкапом и подтверждением)
Пункт [Z] в control-panel.ps1: предпросмотр → бэкап БД → подтверждение вводом
«СБРОС» → очистка. Скрипт backend/scripts/reset-system.js (dry-run по умолчанию,
выполнение только с --apply --confirm=RESET):
• сохраняет одного админа (min id), переназначает ему авторский контент
  (courses/tests/flashcard_decks/custom_sims/шаблоны/библиотека/lab-ссылки/board);
• стирает всех остальных пользователей + классы/задания/сессии/геймификацию/
  уведомления/прогресс/историю классрума/доступы/логи;
• сохраняет контент: учебники, вопросы, темы, уроки, exam-prep, симуляции,
  биохимия, красная книга, магазин/достижения-определения, роли/права, app_settings;
• обнуляет игровые счётчики админа; классифицирует ВСЕ 119 таблиц, неизвестные не трогает;
• FK off + транзакция + foreign_key_check + VACUUM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:44 +03:00
Maxim Dolgolyov c6d323ec6d feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»
Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:42 +03:00
Maxim Dolgolyov c5d440a7a9 fix(tests): режимы доступных тестов только exam/practice + скрытие пустых предметов
Рассогласование: админ-настройка допускала режимы topic/random, но POST /api/sessions
принимает только exam/practice → клик по такому предмету падал с 400. Убрал topic/random
из валидатора subjects.js и из админ-дропдауна (SC_MODES). Дашборд: старые значения
topic/random коэрсятся в practice; предметы без вопросов в банке И без фикс-теста больше
не показываются (раньше давали 404 «No questions found» при запуске).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:53:43 +03:00
Maxim Dolgolyov 1aa95a6776 fix(dashboard): hero «Лаборатория дня» виден при выключенной лабе
Hero-карточка #hc-lab имела href="/lab", но loadLabOfDay меняет его на
/lab?sim=<id> → CSS [href="/lab"] больше не матчит, карточка оставалась видной.
Прячем по стабильному id: #hc-lab/#hc-pet/#hc-read добавлены в FEATURE_WIDGETS
(lab/pet/textbooks). .hero-row переведён на grid auto-fit (minmax 240) — сетка сама
подстраивается под видимые карточки без дыры; syncHeroRow прячет весь ряд, если
карточек не осталось (мобайл-медиазапрос не трогаем — без инлайн-колонок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:37:41 +03:00
Maxim Dolgolyov 399a222b65 fix(dashboard): пустой бокс колонки прогресса, когда флешкарты отключены
#w-flashcard прятался, но он — секция внутри #w-progress-col (один .widget-бокс с
рамкой/паддингом: карточка + прогресс по предметам + результаты). Если все секции
скрыты (флешкарты выкл и нет данных), оставался пустой бокс. Добавлена
syncProgressCol(): прячет #w-progress-col, если ни одна секция не видна (computed-
display, учитывает и инъект-CSS флешкарт). Зовётся в конце loadFlashcardWidget /
loadLastResultsWidget / loadSubjProgressWidget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:26:33 +03:00
Maxim Dolgolyov 796a2416cb chore(admin): секция «Игры» → «Модули» (там уже не только игры)
Вкладка/заголовок админ-панели переименованы: nav «Игры»→«Модули» (иконка
gamepad-2→layout-grid), section-title «Управление играми»→«Управление модулями»,
описание про «отключённые игры»→«отключённые модули». В секции теперь лаба,
теория, путеводитель, доска, классрум и т.д. — не только игры. Free-student
подсекция уже звалась «Модули…» — консистентно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:11:09 +03:00
Maxim Dolgolyov 604fa7ac0b fix(sidebar): убрать мигание ссылок «Подготовка к экзамену» при отключении
Ссылки exam-prep (/exam-prep/*) скрывались отдельным async-механизмом (/api/exam-prep/
tracks), не входящим в синхронный кэш-CSS → мелькали на долю секунды после обновления.
Теперь hideDisabledFeatures кэширует точные хрефы скрытых треков в localStorage
(ls_examhide), а _applyFeatureCss добавляет их в инъект-CSS синхронно из кэша на ранней
загрузке (до сборки сайдбара). При включении трека он убирается из кэша → снова виден
(re-apply _applyFeatureCss после свежего fetch). hideEmptySidebarGroups перенесён в конец
hideDisabledFeatures (учитывает скрытие exam-prep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:56:12 +03:00
Maxim Dolgolyov 38f8be9389 feat(features): тумблер «Путеводитель» (/sitemap)
Пункт «Путеводитель» теперь отключается как остальные модули: ключ sitemap в
вайтлист updateFeatures (backend) + тумблер в admin → фичи (GAME_FEATURES) +
/sitemap,/sitemap.html в FEATURE_HREFS (скрытие из сайдбара + редирект при выкл).
По умолчанию ВКЛ (opt-in disable). Пустая группа схлопнётся авто (hideEmptySidebarGroups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:51:36 +03:00
Maxim Dolgolyov c04a8c2178 fix(sidebar): прятать пустые группы (заголовок без видимых пунктов)
Когда все пункты группы сайдбара скрыты (фичи отключены / teacher-only у ученика),
оставался висеть пустой заголовок-аккордеон (напр. «Практика и игры»). Добавлена
hideEmptySidebarGroups(): по .sb-group проверяет computed-display пунктов .sb-link
в теле и прячет группу, если ни одного видимого. Зовётся синхронно из sidebar.js
после сборки (по кэш-CSS — без мигания) и в конце hideDisabledFeatures (по свежим
данным; re-show при включении).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:51 +03:00
Maxim Dolgolyov 83f0ba9c04 fix(features): пустой блок флешкарт, лаба в сайдбаре, мигание (FOUC)
Три проблемы UX отключения модулей:
1) Пустой блок флешкарт на дашборде: виджет #w-flashcard — <div>, а скрытие шло
   только по [href]. Добавлена карта FEATURE_WIDGETS (флешкарты→#w-flashcard) —
   контейнер прячется целиком.
2) Лаборатория не уходила из сайдбара: не было ГЛОБАЛЬНОГО тумблера lab (только
   free-student). Добавлен в whitelist updateFeatures + GAME_FEATURES; map уже знал
   lab→/lab. Теперь выключение скрывает пункт и редиректит со страницы.
3) Мигание выключенных модулей (FOUC): hideDisabledFeatures асинхронный. Теперь
   loadFeatures кэширует /api/features в localStorage, а _applyFeatureCss инъектит
   <style id=ls-feat-hide> синхронно из кэша на ранней загрузке (api.js идёт до
   sidebar.js) — сайдбар/виджеты строятся уже скрытыми. Геймификация: класс
   no-gamification ставится на <html> (раньше body), ls.css правило body.no-gamification
   → .no-gamification (работает и для html, и для body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:41:11 +03:00
Maxim Dolgolyov d5fbd0168e feat(permissions): +10 прав ролей с энфорсом (Доступ · роли)
Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять:
• Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины),
  simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды).
• Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»),
  assistant.use (ИИ-ассистент), games.play (учебные игры),
  flashcards.access / exam.access (доступ к разделам).
Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру.

Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only);
ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) —
новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка
только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится
из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI —
существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:00 +03:00
Maxim Dolgolyov 54be84e74a fix(admin): глобальный мастер-тумблер «Геймификация» в админ-UI
Геймификация (gamification) была только в FS_FEATURES (free-student grid) — UI
позволял выключить её лишь для роли «свободный ученик», а ГЛОБАЛЬНОГО тумблера в
GAME_FEATURES не было, хотя бэкенд флаг feature_gamification_enabled и kill-switch
это поддерживают. Добавлен в GAME_FEATURES как «Геймификация (всё)» — теперь админ
может выключить XP/монеты/ачивки/магазин/стрики/лидерборд глобально из UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:08:59 +03:00
Maxim Dolgolyov dc71d7b4d9 fix(gamification): полнота kill-switch — испытания/стрик/монеты + гейт счётчиков
Аудит выключателя геймификации выявил элементы, НЕ покрытые body.no-gamification:
испытания недели (#ch-section/.ch-widget), календарь стриков (.streak-cal),
стат-кольцо стрика (#sr-streak), монеты в профиле (#p-coins-row), чипы стрик/цель
на карточке питомца. Добавлены в CSS kill-switch (ls.css). Бэкенд: updateChallenges
и onLabExperiment писали прогресс/счётчики без проверки флага — добавлен гейт
isGamificationEnabled() (XP/coins/achievements уже гейтились в award*-функциях).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:04:30 +03:00
Maxim Dolgolyov d8f2a7f98d fix(features): доска уходит из сайдбара при отключении + тумблер «Теория»
1) Доска при feature_board_enabled=0 не пропадала у учителя/админа: showBoardIfAllowed()
   зовётся напрямую на ~20 страницах и показывала доску БЕЗ проверки флага, перекрывая
   hideDisabledFeatures(). Теперь функция сперва грузит features и при board===false
   держит ссылку скрытой (для всех ролей).
2) Добавлен фич-флаг theory: ключ в вайтлист updateFeatures (backend), тумблер «Теория»
   в admin → games (GAME_FEATURES), и /theory,/theory.html в map hideDisabledFeatures
   (скрытие из сайдбара + редирект с /theory при выключении). По умолчанию ВКЛ (opt-in disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:56:22 +03:00
Maxim Dolgolyov 9d35aaf673 fix(admin/access): нативные confirm() → стилизованная модалка LS.confirm
Раздел «Доступ · контент» использовал браузерный confirm() («Подтвердите
действие на localhost…») для закрытия доступа у всех классов и копирования.
Заменены все 5 вызовов на LS.confirm (та же модалка, что в остальном админ-
разделе): «Закрыть доступ» (danger) и «Скопировать доступ» (danger:false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:48:24 +03:00
Maxim Dolgolyov bd7dd06e47 fix(exam-prep): репаратор рендеринга ctmath — потерянные \ в опциях + <,> в $…$
Root cause: в seed-ах вариантов 101–121 опции писались как mc('$\sqrt..$') в
обычных кавычках вместо R`…` → JS-парсер съедал \s→s, \d→d и т.п., в БД легло
«$sqrt{17}$», «$dfrac{pi}{3}$» (KaTeX рисует «sqrt17», «dfracpi3»). Плюс литеральные
<,> внутри $…$ ломали HTML до KaTeX. Скрипт fix_ctmath_render.js (dry-run/--apply,
идемпотентный): восстанавливает \ перед командами (+нормализует управляющие символы)
в opts_json и заменяет <,>→\lt,\gt внутри $…$ в text/opts/solution. DRY-RUN: 307
строк (143 opts/128 text/157 sol), остаточных багов 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:34:59 +03:00
Maxim Dolgolyov f381873c34 fix(exam-prep): список «Варианты» показывает метку (ЦТ-2015…), а не «Вариант N»
variants.js хардкодил «Вариант ${n}» в гриде-пикере, заголовке и подписи кнопки,
игнорируя поле label из listVariants (бэкенд его уже отдаёт через examVariantLabel).
Добавлен хелпер labelOf(n) → подставляет v.label с фолбэком. mock.js дропдаун уже
использовал label — там достаточно перезапуска сервера, чтобы бэкенд отдал метки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:20:45 +03:00
Maxim Dolgolyov dd69c026ec content(ctmath): вариант 121 — ЦТ-2011 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2011 В1-В10.pdf (тест полный, не только В), все 30
ответов сверены с официальной таблицей (полное совпадение). Уточнения по таблице:
A6 степень 3x+4 (→15·2^3x), A9=(3^-2)^-5→1/9, A7 корень -3, A8=10, A10=10π.
Фигурные A1/A2/B6 реконструированы (B6: y=x²-6x+9 ∩ y=1,25 → 4x₁x₂=31). Все В
числовые. Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 121='ЦТ-2011'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:53:06 +03:00
Maxim Dolgolyov 84625cd72a content(ctmath): вариант 120 — ЦТ-2012 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2012.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A1/A13/B6 реконструированы с явными
данными (A1 — углы 70/40→равнобедренный; B6 — середины сторон→S=4). A15
уточнена по таблице: √(5⁵·20)=250, знаменатель ⁴√10 → 25·⁴√10. Все В числовые.
Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 120='ЦТ-2012'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:42:27 +03:00
Maxim Dolgolyov 0fb16ef85e content(ctmath): вариант 119 — ЦТ-2013 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ2013.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A2/A3/A6/A16 реконструированы с явными
описаниями (A2 — образующая=AD, A6 — порядок лучей→40°, A16 — сечение 12×6=72).
Все В-задания числовые (long нет). Без авторских ссылок. Дедуп-гейт 0,
KaTeX 30/30, DRY-RUN 30/30. VARIANT_LABEL: 119='ЦТ-2013'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:35:28 +03:00
Maxim Dolgolyov b9a82c326e content(ctmath): вариант 118 — ЦТ-2017 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из CT-2017.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A1/A3/A9/A11/A14 реконструированы с
явными числами (A9 — биссектриса, AM/MC=AB/BC→13,8). Без авторских ссылок.
Прогнан через дедуп-гейт (0 совпадений с пулом) + KaTeX-структуру + DRY-RUN 30/30.
VARIANT_LABEL: 118='ЦТ-2017'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:28:06 +03:00
62 changed files with 4497 additions and 251 deletions
+171
View File
@@ -0,0 +1,171 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
fix_ctmath_render.js — починка двух дефектов рендеринга в exam_tasks (ctmath).
ПРИЧИНА (root cause):
В seed-скриптах вариантов 101–121 опции писались как mc('$\sqrt{17}$', ...) —
в ОБЫЧНЫХ кавычках, а не в String.raw `…`. JS-парсер съедал управляющие
эскейпы: \s→s, \d→d (теряется «\»), а \f→0x0C, \t→0x09, \b→0x08, \v,\n,\r —
превращались в УПРАВЛЯЮЩИЕ символы. Итог в БД: «$sqrt{17}$», «$dfrac{pi}{3}$»,
KaTeX рендерит их как «sqrt17», «dfracpi3». (text/solution писались через R`…`
и НЕ пострадали — там «\» на месте.)
ВТОРОЙ ДЕФЕКТ: литеральные < и > ВНУТРИ $…$ (напр. «$-1{,}6<x<-1$»). При вставке
в innerHTML браузер парсит «<x…» как HTML-тег ДО запуска KaTeX → ломает карточку.
Лечится заменой < → \lt, > → \gt (только внутри $…$).
ЧТО ДЕЛАЕТ СКРИПТ (идемпотентно, повторный запуск безопасен):
• opts_json: (1) нормализует управляющие символы обратно в \f \t \b \v \n \r;
(2) восстанавливает «\» перед известными KaTeX-командами; (3) < > → \lt \gt.
• text_html, solution_html: только (3) < > → \lt \gt внутри $…$ (HTML-теги вне
математики не трогаются).
Восстановление «\» применяется ТОЛЬКО к opts_json (text/sol не повреждены).
Запуск:
node backend/scripts/fix_ctmath_render.js # DRY-RUN (показывает правки)
node backend/scripts/fix_ctmath_render.js --apply # запись в БД
⚠️ Запись запускает ПОЛЬЗОВАТЕЛЬ. После --apply — перезапуск сервера не нужен
(данные в БД; фронт перечитает их при следующем запросе), но hard-refresh браузера.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
/* ── Управляющие символы → их LaTeX-эскейп (обратная нормализация) ── */
const CTRL_MAP = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\v': '\\v', '\f': '\\f', '\r': '\\r' };
function normalizeCtrl(s) {
return s.replace(/[\b\t\n\v\f\r]/g, ch => CTRL_MAP[ch] || ch);
}
/* ── Команды, у которых первая буква НЕ из {f,t,b,v,n,r} (их «\» просто пропал,
без управляющего символа). Длинные/составные — РАНЬШЕ коротких префиксов,
чтобы не разорвать слово (dfrac до frac, arccos до cos, leq до le, …). ── */
const BARE_CMDS = [
'arccos', 'arcsin', 'arctg',
'overline', 'operatorname', 'varnothing', 'varphi', 'varepsilon',
'dfrac', 'cdots', 'cdot', 'sqrt', 'left',
'lambda', 'gamma', 'delta', 'sigma', 'omega', 'alpha', 'angle', 'approx',
'infty', 'ldots', 'oplus',
'cos', 'sin', 'cot', 'ctg', 'cup', 'cap', 'leq', 'geq', 'neq',
'sim', 'lim', 'log',
'pm', 'mp', 'le', 'ge', 'ln', 'lg', 'pi', 'mu', 'in',
'phi', 'psi', 'rho', 'chi', 'tau',
];
/* (frac, tfrac, times, theta, tan, tg, text, beta, vec, ne, nu, right, nabla —
приходят из управляющих символов и чинятся normalizeCtrl, поэтому в этом
списке их НЕТ: иначе «dfrac»→«d\frac».) */
function restoreBackslashes(math) {
let s = normalizeCtrl(math);
for (const cmd of BARE_CMDS) {
// «\bcmd», не уже-экранированное (нет «\» перед), как самостоятельное слово
const re = new RegExp('(^|[^\\\\A-Za-z])' + cmd + '(?![A-Za-z])', 'g');
s = s.replace(re, (m, pre) => pre + '\\' + cmd);
}
return s;
}
/* ── < > → \lt \gt ТОЛЬКО внутри $…$ / $$…$$ ── */
function fixAngles(field) {
if (!field) return field;
return String(field).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg =>
seg.replace(/</g, '\\lt ').replace(/>/g, '\\gt '));
}
/* ── Полная починка одной опции (внутри $…$): \ + < > ── */
function fixOptionText(t) {
if (!t) return t;
// обрабатываем содержимое каждого $…$: восстановить «\», затем < >
return String(t).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg => {
const open = seg.startsWith('$$') ? '$$' : '$';
const inner = seg.slice(open.length, seg.length - open.length);
let fixed = restoreBackslashes(inner);
fixed = fixed.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
return open + fixed + open;
});
}
/* ── Открытие БД ── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const rows = db.prepare(
`SELECT id, variant, task_idx, task_type, text_html, opts_json, solution_html
FROM exam_tasks WHERE exam_key=? ORDER BY variant, task_idx`).all(EXAM);
let changedRows = 0, changedOpts = 0, changedText = 0, changedSol = 0;
const samples = [];
const upd = db.prepare(
`UPDATE exam_tasks SET text_html=?, opts_json=?, solution_html=? WHERE id=?`);
if (APPLY) db.exec('BEGIN');
try {
for (const r of rows) {
let newOpts = r.opts_json;
let newText = r.text_html;
let newSol = r.solution_html;
let touched = false;
// opts_json — восстановление «\» + < >
if (r.opts_json) {
try {
const arr = JSON.parse(r.opts_json);
const fixed = arr.map(([l, t]) => [l, fixOptionText(t)]);
const cand = JSON.stringify(fixed);
if (cand !== r.opts_json) { newOpts = cand; changedOpts++; touched = true; }
} catch { /* не-JSON — пропускаем */ }
}
// text_html / solution_html — только < >
const ft = fixAngles(r.text_html);
if (ft !== r.text_html) { newText = ft; changedText++; touched = true; }
const fs = fixAngles(r.solution_html);
if (fs !== r.solution_html) { newSol = fs; changedSol++; touched = true; }
if (touched) {
changedRows++;
if (samples.length < 12) {
samples.push({ v: r.variant, i: r.task_idx,
beforeOpts: r.opts_json && r.opts_json.length > 90 ? r.opts_json.slice(0, 90) + '…' : r.opts_json,
afterOpts: newOpts && newOpts.length > 90 ? newOpts.slice(0, 90) + '…' : newOpts });
}
if (APPLY) upd.run(newText, newOpts, newSol, r.id);
}
}
if (APPLY) db.exec('COMMIT');
} catch (e) {
if (APPLY) db.exec('ROLLBACK');
console.error('✗ Ошибка, откат:', e.message);
db.close();
process.exit(1);
}
console.log(`\n=== fix_ctmath_render (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===`);
console.log(`Всего задач ctmath: ${rows.length}`);
console.log(`Будет изменено строк: ${changedRows} (opts: ${changedOpts}, text: ${changedText}, sol: ${changedSol})`);
console.log(`\nПримеры (opts до → после):`);
for (const s of samples) {
console.log(`\n v${s.v}#${s.i}`);
console.log(` ДО: ${s.beforeOpts}`);
console.log(` ПОСЛЕ: ${s.afterOpts}`);
}
/* контроль остаточных «голых» команд после починки (для self-check в dry-run) */
if (!APPLY) {
const after = db.prepare(`SELECT opts_json FROM exam_tasks WHERE exam_key=? AND opts_json IS NOT NULL`).all(EXAM);
let leftover = 0;
for (const r of after) {
let arr; try { arr = JSON.parse(r.opts_json); } catch { continue; }
for (const [, t] of arr) {
const fixedNow = fixOptionText(t);
// ищем подозрительные «\bdfrac/sqrt/frac…» БЕЗ слэша уже ПОСЛЕ починки
if (/(^|[^\\A-Za-z])(sqrt|dfrac|frac|tfrac|cdot|times|alpha|beta|theta|pi)(?![A-Za-z])/.test(fixedNow.replace(/\$/g,''))) leftover++;
}
}
console.log(`\nКонтроль: потенциально не починенных опций после прогона: ${leftover}`);
console.log(`\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/fix_ctmath_render.js --apply\n`);
}
db.close();
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js).
⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET.
Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически).
Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system).
Запуск:
node backend/scripts/reset-system.js # план
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const reset = require('../src/services/systemReset');
const APPLY = process.argv.includes('--apply');
const CONFIRM = process.argv.includes('--confirm=RESET');
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const keptAdmin = reset.pickKeptAdmin(db);
if (!keptAdmin) {
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
db.close(); process.exit(1);
}
const plan = reset.classify(db);
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`);
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`);
console.log('REASSIGN (контент → админу):');
plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`));
console.log('\nWIPE (полная очистка):');
plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`));
console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`);
console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`);
if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`);
if (!APPLY) {
console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
db.close(); process.exit(0);
}
if (!CONFIRM) {
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
db.close(); process.exit(1);
}
try {
const res = reset.runReset(db, keptAdmin.id);
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`);
console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`);
if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`);
console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`);
} catch (e) {
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
db.close(); process.exit(1);
}
db.close();
+352
View File
@@ -0,0 +1,352 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2011_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2011, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2011\ЦТ 2011 В1-В10.pdf
(несмотря на имя «В1-В10», тест полный: А1–А18 + В1–В12; ответы — «Ответы 2011.pdf», столбец 1).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B2=150, B8=16, B10=10, B12=26. variant=121. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Уточнения по таблице (скан неоднозначен по степеням/индексам):
• А6: степень $3x+4$ → $2^{3x+4}-2^{3x}=15\cdot2^{3x}$;
А9: $3^{-12}\cdot(3^{-2})^{-5}=3^{-2}=\tfrac19$;
• А7: корень уравнения с радикалом = $-3$ (корень линейного множителя вне ОДЗ отброшен);
• А10: осевое сечение $=10$ → боковая $=10\pi$.
Реконструкции «с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (tg не определена) → точки в тексте, ровно одна вида $\tfrac{\pi}{2}+\pi k$ ($-\tfrac{5\pi}{2}$);
• А2 (параллелограмм на сетке) → основание/высота числами ($5\times4=20$);
• B6 (парабола+прямая) → парабола $y=x^2-6x+9$ и прямая $y=1{,}25$ заданы явно ($4x_1x_2=31$).
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2011_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2011_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 121;
const N_TASKS = 30;
const PROV = 'ЦТ–2011, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'trigonometry', subtopic: 'trig-circle', diff: 1,
text: R`Функция $y=\operatorname{tg}x$ не определена в точке:`,
opts: mc('$2\pi$', '$-\dfrac{5\pi}{2}$', '$\dfrac{2\pi}{5}$', '$\dfrac{\pi}{4}$', '$-3\pi$'),
answer: 'б',
sol: R`$\operatorname{tg}x$ не определён при $x=\dfrac{\pi}{2}+\pi k$. Из перечисленных таково $-\dfrac{5\pi}{2}=\dfrac{\pi}{2}-3\pi$.` },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 1,
text: R`Параллелограмм изображён на клетчатой бумаге с клетками $1\times1$ см: его основание равно $5$ см, а высота, проведённая к этому основанию, равна $4$ см. Найдите площадь параллелограмма (в квадратных сантиметрах).`,
opts: mc('$10$', '$25$', '$15$', '$20$', '$18$'),
answer: 'г',
sol: R`Площадь параллелограмма $=$ основание $\times$ высоту $=5\cdot4=20$ см².` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Если $7\tfrac29:x=4\tfrac13:3\tfrac35$ — верная пропорция, то число $x$ равно:`,
opts: mc('$\dfrac23$', '$6$', '$\dfrac54$', '$\dfrac49$', '$1{,}5$'),
answer: 'б',
sol: R`$x=\dfrac{7\tfrac29\cdot3\tfrac35}{4\tfrac13}=\dfrac{\tfrac{65}{9}\cdot\tfrac{18}{5}}{\tfrac{13}{3}}=\dfrac{26}{\tfrac{13}{3}}=6$.` },
{ idx: 4, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
text: R`Если $15\%$ некоторого числа равны $33$, то $20\%$ этого числа равны:`,
opts: mc('$44$', '$46$', '$55$', '$56$', '$66$'),
answer: 'а',
sol: R`Число $=\dfrac{33}{0{,}15}=220$, тогда $20\%=0{,}2\cdot220=44$.` },
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 1,
text: R`Если $9x-24=0$, то $18x-31$ равно:`,
opts: mc('$13$', '$-17$', '$17$', '$21$', '$-19$'),
answer: 'в',
sol: R`$x=\dfrac{24}{9}=\dfrac83$, поэтому $18x=48$ и $18x-31=17$.` },
{ idx: 6, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Для любого числа $x$ выражение $2^{3x+4}-2^{3x}$ равно:`,
opts: mc('$15\cdot2^{3x}$', '$16$', '$2^{6x+1}$', '$\dfrac23\cdot2^{3x}$', '$2^{3x}$'),
answer: 'а',
sol: R`$2^{3x+4}-2^{3x}=2^{3x}\left(2^{4}-1\right)=15\cdot2^{3x}$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-irrational', diff: 2,
text: R`Сумма корней (корень, если он один) уравнения $(x+5)\sqrt{x+3}=0$ равна:`,
opts: mc('$-1$', '$3$', '$1$', '$-3$', '$-2$'),
answer: 'г',
sol: R`ОДЗ: $x\ge-3$. Корень $x=-5$ не входит в ОДЗ, остаётся $x=-3$ (из $\sqrt{x+3}=0$). Единственный корень $-3$.` },
{ idx: 8, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`От листа жести, имеющего форму квадрата, отрезали прямоугольную полосу шириной $7$ дм, после чего площадь оставшейся части листа оказалась равной $30$ дм². Длина стороны квадратного листа (в дециметрах) была равна:`,
opts: mc('$11$', '$12$', '$3$', '$9$', '$10$'),
answer: 'д',
sol: R`Если сторона квадрата $a$, то $a(a-7)=30$, $a^{2}-7a-30=0$, $a=10$ (второй корень $-3$ отброшен).` },
{ idx: 9, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Значение выражения $3^{-12}\cdot\left(3^{-2}\right)^{-5}$ равно:`,
opts: mc('$81$', '$3^{-22}$', '$9$', '$3^{-12}$', '$\dfrac19$'),
answer: 'д',
sol: R`$\left(3^{-2}\right)^{-5}=3^{10}$, поэтому $3^{-12}\cdot3^{10}=3^{-2}=\dfrac19$.` },
{ idx: 10, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Площадь осевого сечения цилиндра равна $10$. Площадь его боковой поверхности равна:`,
opts: mc('$5\pi$', '$10\pi$', '$20\pi$', '$100\pi$', '$10$'),
answer: 'б',
sol: R`Осевое сечение — прямоугольник $2r\times h$ площадью $2rh=10$. Боковая поверхность $=2\pi rh=\pi\cdot2rh=10\pi$.` },
{ idx: 11, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Найдите значение выражения $230\cdot\dfrac29-\left(\dfrac29+\dfrac1{10}\right):\dfrac1{230}$.`,
opts: mc('$0{,}1$', '$43\tfrac49$', '$-0{,}1$', '$-23$', '$23$'),
answer: 'г',
sol: R`$230\cdot\dfrac29=\dfrac{460}{9}$; $\left(\dfrac{29}{90}\right)\cdot230=\dfrac{667}{9}$. Разность $\dfrac{460-667}{9}=-\dfrac{207}{9}=-23$.` },
{ idx: 12, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Упростите выражение $\dfrac{x^{2}-22x+121}{x^{2}-11x}:\dfrac{x^{2}-121}{x^{3}}$.`,
opts: mc('$\dfrac{x}{x+11}$', '$\dfrac{(x-11)^{2}}{x^{4}}$', '$\dfrac{x-11}{x+11}$', '$\dfrac{x^{2}}{x-11}$', '$\dfrac{x^{2}}{x+11}$'),
answer: 'д',
sol: R`$\dfrac{(x-11)^{2}}{x(x-11)}\cdot\dfrac{x^{3}}{(x-11)(x+11)}=\dfrac{x^{2}}{x+11}$.` },
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Параллельно стороне треугольника, равной $5$, проведена прямая. Длина отрезка этой прямой, заключённого между сторонами треугольника, равна $2$. Найдите отношение площади полученной трапеции к площади исходного треугольника.`,
opts: mc('$\dfrac25$', '$0{,}6$', '$\dfrac{21}{25}$', '$\dfrac{4}{25}$', '$\dfrac{3}{25}$'),
answer: 'в',
sol: R`Отсечённый треугольник подобен исходному с коэффициентом $\dfrac25$, его площадь составляет $\dfrac{4}{25}$. Трапеция: $1-\dfrac{4}{25}=\dfrac{21}{25}$.` },
{ idx: 14, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Сумма координат точки пересечения прямых, заданных уравнениями $2x+5y=11$ и $x+y=2(5-y)$, равна:`,
opts: mc('$8$', '$-8$', '$10$', '$-10$', '$6$'),
answer: 'б',
sol: R`Второе уравнение: $x+3y=10$. Из системы $y=9$, $x=-17$. Сумма координат $-17+9=-8$.` },
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Количество целых решений неравенства $\dfrac{(x+3)^{2}-6x-18}{(x-5)^{2}}>0$ на промежутке $[-4;5]$ равно:`,
opts: mc('$2$', '$7$', '$4$', '$5$', '$3$'),
answer: 'а',
sol: R`Числитель $(x+3)^{2}-6x-18=x^{2}-9$. При $x\ne5$ знаменатель положителен, поэтому неравенство равносильно $x^{2}-9>0$, то есть $x<-3$ или $x>3$. На $[-4;5]$ это $x=-4$ и $x=4$$2$ решения.` },
{ idx: 16, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 3,
text: R`В ромб площадью $18\sqrt5$ вписан круг площадью $5\pi$. Сторона ромба равна:`,
opts: mc('$8$', '$18$', '$\dfrac{9\sqrt5}{5}$', '$\dfrac{18\sqrt5}{5}$', '$9$'),
answer: 'д',
sol: R`Радиус вписанного круга: $\pi r^{2}=5\pi$, $r=\sqrt5$; высота ромба $h=2r=2\sqrt5$. Площадь $=a\cdot h$: $18\sqrt5=a\cdot2\sqrt5$, $a=9$.` },
{ idx: 17, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Расположите числа $\sqrt[12]{80}$; $\sqrt[3]{3}$; $\sqrt[4]{4}$ в порядке возрастания.`,
opts: mc('$\sqrt[4]{4};\ \sqrt[3]{3};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[4]{4};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[12]{80};\ \sqrt[4]{4}$', '$\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$', '$\sqrt[12]{80};\ \sqrt[3]{3};\ \sqrt[4]{4}$'),
answer: 'г',
sol: R`Возведём в $12$-ю степень: $\left(\sqrt[12]{80}\right)^{12}=80$, $\left(\sqrt[3]{3}\right)^{12}=81$, $\left(\sqrt[4]{4}\right)^{12}=64$. Так как $64<80<81$, порядок: $\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$.` },
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
text: R`Найдите наименьший положительный корень уравнения $4\sin^{2}x+12\cos x-9=0$.`,
opts: mc('$\dfrac{2\pi}{3}$', '$\arccos\dfrac52$', '$\dfrac{\pi}{3}$', '$\dfrac{\pi}{6}$', '$\pi-\arccos\dfrac52$'),
answer: 'в',
sol: R`$4(1-\cos^{2}x)+12\cos x-9=0$, то есть $4\cos^{2}x-12\cos x+5=0$, $\cos x=\dfrac12$ (второй корень $\dfrac52$ невозможен). Наименьший положительный корень $\dfrac{\pi}{3}$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Найдите произведение корней уравнения $\dfrac{3}{x+1}+1=\dfrac{10}{x^{2}+2x+1}$.`,
answer: '-6',
sol: R`Пусть $u=x+1$: $\dfrac3u+1=\dfrac{10}{u^{2}}$, $u^{2}+3u-10=0$, $u=2$ или $u=-5$. Тогда $x=1$ или $x=-6$, произведение $-6$.` },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`Диагонали трапеции равны $15$ и $20$. Найдите площадь трапеции, если её средняя линия равна $12{,}5$.`,
answer: '150',
sol: R`Площадь трапеции равна площади треугольника со сторонами, равными диагоналям ($15$ и $20$), и основанием, равным сумме оснований $=2\cdot12{,}5=25$. Так как $15^{2}+20^{2}=25^{2}$, треугольник прямоугольный: площадь $=\tfrac12\cdot15\cdot20=150$.` },
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите сумму корней (или корень, если он один) уравнения $2\cdot6^{\log_7 x}=108-x^{\log_7 6}$.`,
answer: '49',
sol: R`Так как $x^{\log_7 6}=6^{\log_7 x}$, обозначим $t=6^{\log_7 x}$: $2t=108-t$, $t=36=6^{2}$. Тогда $\log_7 x=2$, $x=49$ — единственный корень.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите сумму целых решений неравенства $2^{3x+4}-10\cdot4^{x}+2^{x}\le0$.`,
answer: '-6',
sol: R`Пусть $u=2^{x}>0$: $16u^{3}-10u^{2}+u\le0$, $u(16u^{2}-10u+1)\le0$, $\dfrac18\le u\le\dfrac12$. Значит $-3\le x\le-1$; сумма целых $-3-2-1=-6$.` },
{ idx: 23, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`По двум перпендикулярным прямым, которые пересекаются в точке $O$, движутся две точки $M_1$ и $M_2$ по направлению к точке $O$ со скоростями $1$ м/с и $2$ м/с соответственно. Достигнув точки $O$, они продолжают своё движение. В первоначальный момент времени $M_1O=5$ м, $M_2O=20$ м. Через сколько секунд расстояние между точками $M_1$ и $M_2$ будет минимальным?`,
answer: '9',
sol: R`Расстояние: $d^{2}=(5-t)^{2}+(20-2t)^{2}=5t^{2}-90t+425$. Минимум при $t=\dfrac{90}{10}=9$ с.` },
{ idx: 24, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 4,
text: R`Парабола $y=x^{2}-6x+9$ и горизонтальная прямая $y=1{,}25$ пересекаются в точках с абсциссами $x_1$ и $x_2$. Найдите значение выражения $4x_1\cdot x_2$.`,
answer: '31',
sol: R`$x^{2}-6x+9=1{,}25$, то есть $x^{2}-6x+7{,}75=0$. По теореме Виета $x_1 x_2=7{,}75$, поэтому $4x_1 x_2=31$.` },
{ idx: 25, type: 'open', topic: 'planimetry', subtopic: 'plan-circle', diff: 4,
text: R`Четырёхугольник $ABCD$ вписан в окружность. Если $\angle BAC=40^\circ$ и $\angle ABD=75^\circ$, то градусная мера угла между прямыми $AB$ и $CD$ равна … .`,
answer: '35',
sol: R`$\angle BAC=40^\circ$ опирается на дугу $BC=80^\circ$, $\angle ABD=75^\circ$ — на дугу $AD=150^\circ$. Угол между прямыми $AB$ и $CD$ равен полуразности дуг: $\dfrac{150^\circ-80^\circ}{2}=35^\circ$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $\dfrac{\sin^{2}184^\circ}{4\sin^{2}23^\circ\cdot\sin^{2}2^\circ\cdot\sin^{2}44^\circ\cdot\sin^{2}67^\circ}$.`,
answer: '16',
sol: R`$\sin67^\circ=\cos23^\circ$ и $\sin46^\circ=\cos44^\circ$ дают знаменатель $=\dfrac1{16}\sin^{2}4^\circ$. Числитель $\sin^{2}184^\circ=\sin^{2}4^\circ$. Отношение $=16$.` },
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`В арифметической прогрессии $130$ членов, их сумма равна $130$, а сумма членов с чётными номерами на $130$ больше суммы членов с нечётными номерами. Найдите сотый член этой прогрессии.`,
answer: '70',
sol: R`Сумма чётных членов равна $130$, нечётных — $0$. Разность сумм $=65d=130$, поэтому $d=2$. Из общей суммы $a_1=-128$, тогда $a_{100}=a_1+99d=-128+198=70$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
text: R`В равнобокой трапеции бóльшее основание вдвое больше каждой из остальных сторон и лежит в плоскости $\alpha$. Боковая сторона образует с плоскостью $\alpha$ угол, синус которого равен $\dfrac{5\sqrt3}{18}$. Найдите $36\sin\beta$, где $\beta$ — угол между диагональю трапеции и плоскостью $\alpha$.`,
answer: '10',
sol: R`Пусть боковая сторона $=b$, тогда основания $b$ и $2b$, высота трапеции $\dfrac{b\sqrt3}{2}$. Из условия $\dfrac{\sqrt3}{2}\sin\theta=\dfrac{5\sqrt3}{18}$ получаем $\sin\theta=\dfrac59$. Длина диагонали $=b\sqrt3$, и $\sin\beta=\dfrac{\sin\theta}{2}=\dfrac{5}{18}$. Значит $36\sin\beta=10$.` },
{ idx: 29, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 5,
text: R`Количество целых решений неравенства $2^{x+6}+\log_{0{,}5}(6-x)>13$ равно … .`,
answer: '7',
sol: R`ОДЗ: $x<6$. При $x=-2$ левая часть равна ровно $13$ (не годится), при $x\le-3$ меньше $13$, а при $-1\le x\le5$ — больше $13$. Целые решения: $-1,0,1,2,3,4,5$ — всего $7$.` },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`Основанием пирамиды $SABCD$ является ромб со стороной $2\sqrt3$ и углом $BAD$, равным $\arccos\dfrac34$. Ребро $SD$ перпендикулярно основанию, а ребро $SB$ образует с основанием угол $60^\circ$. Найдите радиус $R$ сферы, проходящей через точки $A$, $B$, $C$ и середину ребра $SB$. В ответ запишите $R^{2}$.`,
answer: '26',
sol: R`Диагональ $BD=\sqrt{2\cdot12\left(1-\tfrac34\right)}=\sqrt6$, $SD=BD\cdot\operatorname{tg}60^\circ=3\sqrt2$. В координатах с центром ромба: $A(0;\tfrac{\sqrt{42}}2;0)$, $B(\tfrac{\sqrt6}2;0;0)$, $C(0;-\tfrac{\sqrt{42}}2;0)$, середина $SB$ $=(0;0;\tfrac{3\sqrt2}2)$. Центр сферы $\left(-\tfrac{3\sqrt6}2;0;-\sqrt2\right)$, $R^{2}=\tfrac{54}{4}+\tfrac{42}{4}+2=26$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2011_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2011_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2011».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+348
View File
@@ -0,0 +1,348 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2012_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2012, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2012\ЦТ 2012.pdf
(ответы — отдельный файл «Ответы 2012.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B7=9, B10=84, B11=90, B12=-180. variant=120. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (равнобедренный треугольник) → пары углов даны числами (70°,40° → равнобедренный, №3);
• А13 (прямая/плоскость/двугранный угол) → все данные в тексте (площадь 14√3);
• B6 (середины сторон прямоугольника) → расположение M,N,P,Q задано в тексте (площадь 4).
А15 уточнена по таблице: радикал $\sqrt{5^{5}\cdot20}=250$, знаменатель $\sqrt[4]{10}$ → $25\sqrt[4]{10}$.
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2012_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2012_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 120;
const N_TASKS = 30;
const PROV = 'ЦТ–2012, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
text: R`У каждого из пяти треугольников на рисунке известны два угла. Укажите номер треугольника, который является равнобедренным: $1)\ 55^\circ$ и $40^\circ$; $\ 2)\ 60^\circ$ и $40^\circ$; $\ 3)\ 70^\circ$ и $40^\circ$; $\ 4)\ 65^\circ$ и $40^\circ$; $\ 5)\ 75^\circ$ и $40^\circ$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Третий угол равен $180^\circ$ минус два данных. Для пары $70^\circ$ и $40^\circ$ третий угол $=70^\circ$, появляются два равных угла — треугольник равнобедренный (№3).` },
{ idx: 2, type: 'mc', topic: 'expressions', subtopic: 'expr-logarithms', diff: 2,
text: R`Укажите верное равенство:<br>$1)\ 3^{\log_3 3}=5$; $\ 2)\ \log_7 7=7$; $\ 3)\ \log_{31}\dfrac{1}{31}=-1$; $\ 4)\ \log_5 25=5$; $\ 5)\ \log_{23} 23=0$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`$\log_{31}\dfrac{1}{31}=\log_{31}31^{-1}=-1$ — верно (равенство 3). Остальные ложны: $3^{\log_3 3}=3$, $\log_7 7=1$, $\log_5 25=2$, $\log_{23}23=1$.` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Сумма всех натуральных делителей числа $28$ равна:`,
opts: mc('$55$', '$11$', '$9$', '$27$', '$56$'),
answer: 'д',
sol: R`Делители $28$: $1,2,4,7,14,28$. Их сумма $=56$.` },
{ idx: 4, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`Даны квадратные уравнения: $1)\ 4x^{2}-3x-3=0$; $\ 2)\ 5x^{2}+20x+20=0$; $\ 3)\ 2x^{2}+3x+12=0$; $\ 4)\ 7x^{2}-4x-5=0$; $\ 5)\ 4x^{2}+8x+4=0$. Укажите уравнение, которое не имеет корней.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Корней нет при $D<0$. Для $2x^{2}+3x+12=0$: $D=9-96=-87<0$ (№3). У остальных $D\ge0$.` },
{ idx: 5, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Если $10^{2}\cdot\alpha=741{,}63287$, то значение $\alpha$ с точностью до сотых равно:`,
opts: mc('$74{,}16$', '$7{,}42$', '$7{,}41$', '$74\,163{,}29$', '$7416{,}33$'),
answer: 'б',
sol: R`$\alpha=\dfrac{741{,}63287}{100}=7{,}4163287\approx7{,}42$.` },
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 2,
text: R`Число $133$ является членом арифметической прогрессии $4,\ 7,\ 10,\ 13,\ \ldots$ Укажите его номер.`,
opts: mc('$44$', '$42$', '$40$', '$46$', '$48$'),
answer: 'а',
sol: R`$a_n=4+3(n-1)=3n+1$. Из $3n+1=133$ получаем $n=44$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-modulus', diff: 2,
text: R`Решите неравенство $|-x|\ge5$.`,
opts: mc('$x\in[5;+\infty)$', '$x\in(-\infty;-5]$', '$x\in[-5;5]$', '$x\in(-\infty;-5]\cup[5;+\infty)$', '$x_1=-5,\ x_2=5$'),
answer: 'г',
sol: R`$|-x|=|x|\ge5$ равносильно $x\le-5$ или $x\ge5$, то есть $x\in(-\infty;-5]\cup[5;+\infty)$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Вычислите $\dfrac{3{,}2+0{,}8:\left(\tfrac16+\tfrac13\right)}{0{,}1}$.`,
opts: mc('$48$', '$0{,}48$', '$4{,}8$', '$80$', '$0{,}8$'),
answer: 'а',
sol: R`$\tfrac16+\tfrac13=\tfrac12$, $0{,}8:\tfrac12=1{,}6$, числитель $=3{,}2+1{,}6=4{,}8$. Делим на $0{,}1$: $48$.` },
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-circles', diff: 1,
text: R`Площадь круга равна $81\pi$. Диаметр этого круга равен:`,
opts: mc('$18$', '$18\pi$', '$9$', '$9\pi$', '$81$'),
answer: 'а',
sol: R`$\pi r^{2}=81\pi$, $r=9$, диаметр $=18$.` },
{ idx: 10, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 2,
text: R`Найдите наименьший положительный корень уравнения $\sin2x=\dfrac12$.`,
opts: mc('$\dfrac{\pi}{6}$', '$\dfrac{\pi}{12}$', '$\dfrac{\pi}{3}$', '$\dfrac{5\pi}{12}$', '$\dfrac{\pi}{8}$'),
answer: 'б',
sol: R`$2x=\dfrac{\pi}{6}+2\pi k$ или $2x=\dfrac{5\pi}{6}+2\pi k$, поэтому $x=\dfrac{\pi}{12}+\pi k$ или $x=\dfrac{5\pi}{12}+\pi k$. Наименьший положительный — $\dfrac{\pi}{12}$.` },
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Четырёхугольник $MNPK$, в котором $\angle N=128^\circ$, вписан в окружность. Найдите градусную меру угла $K$.`,
opts: mc('$64^\circ$', '$128^\circ$', '$100^\circ$', '$180^\circ$', '$52^\circ$'),
answer: 'д',
sol: R`У вписанного четырёхугольника суммы противоположных углов равны $180^\circ$. Углы $N$ и $K$ противоположны, поэтому $\angle K=180^\circ-128^\circ=52^\circ$.` },
{ idx: 12, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`На одной чаше уравновешенных весов лежат $3$ яблока и $1$ груша, на другой — $2$ яблока, $2$ груши и гирька весом $20$ г. Каков вес одного яблока (в граммах), если все фрукты вместе весят $780$ г? Считайте все яблоки одинаковыми по весу и все груши одинаковыми по весу.`,
opts: mc('$95$', '$105$', '$100$', '$125$', '$115$'),
answer: 'б',
sol: R`Равновесие: $3a+p=2a+2p+20$, то есть $a-p=20$. Все фрукты: $5a+3p=780$. Отсюда $a=105$, $p=85$.` },
{ idx: 13, type: 'mc', topic: 'stereometry', subtopic: 'ster-lines-planes', diff: 3,
text: R`Прямая $a$, параллельная плоскости $\alpha$, находится от неё на расстоянии $6$. Через прямую $a$ проведена плоскость $\beta$, пересекающая плоскость $\alpha$ по прямой $b$ и образующая с ней угол $60^\circ$. Найдите площадь четырёхугольника $ABCD$, если $A$ и $B$ — точки прямой $a$, причём $AB=4$, а $C$ и $D$ — такие точки прямой $b$, что $CD=3$.`,
opts: mc('$42$', '$42\sqrt3$', '$\dfrac{21\sqrt3}{2}$', '$10{,}5$', '$14\sqrt3$'),
answer: 'д',
sol: R`Прямые $a$ и $b$ параллельны, поэтому $ABCD$ — трапеция с основаниями $AB=4$ и $CD=3$. Её высота (расстояние между $a$ и $b$ в плоскости $\beta$) равна $\dfrac{6}{\sin60^\circ}=4\sqrt3$. Площадь $=\dfrac{4+3}{2}\cdot4\sqrt3=14\sqrt3$.` },
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Упростите выражение $\dfrac{125^{x}+25^{x}-12\cdot5^{x}}{5^{x}\left(5^{x}-3\right)}$.`,
opts: mc('$5^{x}$', '$125^{x}-4$', '$5^{x}+4$', '$5^{x}-4$', '$2\cdot5^{x}$'),
answer: 'в',
sol: R`Пусть $u=5^{x}$. Числитель $=u^{3}+u^{2}-12u=u(u+4)(u-3)$, знаменатель $=u(u-3)$. Дробь $=u+4=5^{x}+4$.` },
{ idx: 15, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Корень уравнения $\sqrt{10}\cdot x=\dfrac{\sqrt{5^{5}\cdot20}}{\sqrt[4]{10}}$ равен:`,
opts: mc('$25\sqrt[4]{10}$', '$50\sqrt2$', '$25\sqrt[5]{50}$', '$4\sqrt[3]{20}$', '$10\sqrt{10}$'),
answer: 'а',
sol: R`$\sqrt{5^{5}\cdot20}=\sqrt{5^{6}\cdot4}=5^{3}\cdot2=250$, поэтому $x=\dfrac{250}{\sqrt{10}\cdot\sqrt[4]{10}}=\dfrac{250}{10^{3/4}}=25\cdot10^{1/4}=25\sqrt[4]{10}$.` },
{ idx: 16, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Какая из прямых $1)\ y=-3$; $\ 2)\ y=-1{,}5$; $\ 3)\ y=0$; $\ 4)\ y=4{,}3$; $\ 5)\ y=2$ пересекает график функции $y=\dfrac14 x^{2}-3x+11$ в двух точках?`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'г',
sol: R`Вершина параболы: $x=6$, $y_{\min}=\dfrac14\cdot36-18+11=2$, ветви вверх. Прямая $y=c$ пересекает график в двух точках при $c>2$. Это $y=4{,}3$ (№4).` },
{ idx: 17, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Если $\dfrac{5x}{y}=\dfrac12$, то значение выражения $\dfrac{3y+9x}{13x-y}$ равно:`,
opts: mc('$12$', '$13$', '$\dfrac{11}{7}$', '$\dfrac{93}{129}$', '$\dfrac{1}{13}$'),
answer: 'б',
sol: R`Из $\dfrac{5x}{y}=\dfrac12$ следует $y=10x$. Тогда $\dfrac{3\cdot10x+9x}{13x-10x}=\dfrac{39x}{3x}=13$.` },
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Наименьшее целое решение неравенства $\lg(x^{2}-2x-8)-\lg(x+2)\le\lg4$ равно:`,
opts: mc('$1$', '$-2$', '$4$', '$5$', '$8$'),
answer: 'г',
sol: R`ОДЗ: $x>4$. На нём $\dfrac{x^{2}-2x-8}{x+2}=x-4$, и неравенство $\lg(x-4)\le\lg4$ даёт $x\le8$. Итого $4<x\le8$; наименьшее целое — $5$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`Если в правильной четырёхугольной пирамиде высота равна $4$, а площадь диагонального сечения равна $12$, то её объём равен … .`,
answer: '24',
sol: R`Диагональное сечение — треугольник с основанием $d$ (диагональ квадрата) и высотой $4$: $\tfrac12 d\cdot4=12$, $d=6$. Сторона основания $a=\dfrac{d}{\sqrt2}=3\sqrt2$, площадь основания $=18$. Объём $=\tfrac13\cdot18\cdot4=24$.` },
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите количество всех целых решений неравенства $\dfrac{64x-x^{3}}{5x}>0$.`,
answer: '14',
sol: R`При $x\ne0$ неравенство равносильно $\dfrac{64-x^{2}}{5}>0$, то есть $-8<x<8$. Целые (без $0$): от $-7$ до $7$ — это $14$ чисел.` },
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 3,
text: R`Точки $A(1;2)$, $B(5;6)$ и $C(8;6)$ — вершины трапеции $ABCD$ ($AD\parallel BC$). Найдите сумму координат точки $D$, если $BD=4\sqrt2$.`,
answer: '11',
sol: R`$BC$ горизонтальна, значит $AD$ тоже горизонтальна и $D$ имеет ординату $2$. Из $BD^{2}=(d-5)^{2}+16=32$ получаем $d=9$ ($d=1$ даёт $D=A$). Тогда $D(9;2)$, сумма координат $11$.` },
{ idx: 22, type: 'open', topic: 'planimetry', subtopic: 'plan-polygons', diff: 3,
text: R`Найдите периметр правильного шестиугольника, меньшая диагональ которого равна $10\sqrt3$.`,
answer: '60',
sol: R`У правильного шестиугольника меньшая диагональ равна $a\sqrt3$, поэтому $a\sqrt3=10\sqrt3$, $a=10$. Периметр $=6a=60$.` },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите произведение корней уравнения $4^{x^{2}}+128=3^{1-x^{2}}\cdot12^{x^{2}}$.`,
answer: '-3',
sol: R`Пусть $u=x^{2}$. Так как $3^{1-u}\cdot12^{u}=3\cdot4^{u}$, уравнение даёт $4^{u}+128=3\cdot4^{u}$, $4^{u}=64$, $u=3$. Тогда $x=\pm\sqrt3$, произведение корней $-3$.` },
{ idx: 24, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 5,
text: R`Площадь прямоугольника $ABCD$ равна $20$. Точки $M$, $N$, $P$, $Q$ — середины его сторон $AB$, $BC$, $CD$, $DA$ соответственно. Найдите площадь четырёхугольника, заключённого между прямыми $AN$, $BP$, $CQ$ и $DM$.`,
answer: '4',
sol: R`Прямые $AN\parallel CQ$ и $BP\parallel DM$, поэтому внутренний четырёхугольник — параллелограмм. Координатный расчёт показывает, что его площадь составляет $\dfrac15$ площади прямоугольника: $\dfrac{20}{5}=4$.` },
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Решите уравнение $x^{2}-7x+10=\dfrac{7}{x^{2}-11x+28}$ и найдите сумму его корней.`,
answer: '9',
sol: R`Уравнение приводится к $(x^{2}-9x+21)(x^{2}-9x+13)=0$. Первый множитель действительных корней не имеет ($D<0$), второй даёт корни с суммой $9$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $16\sin\left(\alpha-\dfrac{\pi}{4}\right)$, если $\sin2\alpha=\dfrac{23}{32}$ и $2\alpha\in\left(0;\dfrac{\pi}{2}\right)$.`,
answer: '-6',
sol: R`$16\sin\left(\alpha-\tfrac{\pi}{4}\right)=8\sqrt2(\sin\alpha-\cos\alpha)$. Так как $(\sin\alpha-\cos\alpha)^{2}=1-\sin2\alpha=\tfrac{9}{32}$ и при $\alpha<\tfrac{\pi}{4}$ разность отрицательна, $\sin\alpha-\cos\alpha=-\tfrac{3\sqrt2}{8}$. Значение $=8\sqrt2\cdot\left(-\tfrac{3\sqrt2}{8}\right)=-6$.` },
{ idx: 27, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
text: R`Найдите сумму целых значений $x$, принадлежащих области определения функции $y=\log_{2-x}\left(12-x-x^{2}\right)$.`,
answer: '-6',
sol: R`Условия: $2-x>0$, $2-x\ne1$ и $12-x-x^{2}>0$. Получаем $-4<x<2$, $x\ne1$. Целые: $-3,-2,-1,0$; их сумма $-6$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
text: R`Прямоугольный треугольник с катетами, равными $6$ и $2\sqrt7$, вращается вокруг оси, содержащей его гипотенузу. Найдите значение выражения $\dfrac{2V}{\pi}$, где $V$ — объём фигуры вращения.`,
answer: '84',
sol: R`Гипотенуза $=\sqrt{36+28}=8$, высота к ней $h=\dfrac{6\cdot2\sqrt7}{8}=\dfrac{3\sqrt7}{2}$. Фигура — два конуса с общим основанием: $V=\tfrac13\pi h^{2}\cdot8=\tfrac13\pi\cdot\tfrac{63}{4}\cdot8=42\pi$. Тогда $\dfrac{2V}{\pi}=84$.` },
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 5,
text: R`Из двух растворов с различным процентным содержанием спирта массой $100$ г и $900$ г отлили по одинаковому количеству. Каждый из отлитых растворов долили в остаток другого раствора, после чего процентное содержание спирта в обоих растворах стало одинаковым. Найдите, сколько раствора (в граммах) было отлито из каждого раствора.`,
answer: '90',
sol: R`Пусть отлито по $m$ г. Равенство итоговых концентраций приводит к $(900-10m)(c_1-c_2)=0$. Поскольку концентрации различны, $900-10m=0$, $m=90$.` },
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 5,
text: R`Найдите произведение корней уравнения $x-\sqrt{x^{2}-36}=\dfrac{(x-6)^{2}}{2x+12}$.`,
answer: '-180',
sol: R`ОДЗ: $|x|\ge6$. После преобразований и возведения в квадрат получаем $x^{4}-168x^{2}-2160=0$, откуда $x^{2}=180$, то есть $x=\pm6\sqrt5$ (оба корня подходят). Произведение $=-180$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2012_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2012_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2012».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+348
View File
@@ -0,0 +1,348 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2013_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2013, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2013\ЦТ2013.pdf
(ответы — отдельный файл «Ответы ЦТ 2013.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B3=75, B9=40, B10=6, B12=-5. variant=119. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А2 (образующая цилиндра) → взаимное расположение точек дано в тексте (AD ⟂ основаниям → AD);
• А3 (точка на графике) → прямая задана как $y=13$, точки перечислены (T(-7;13));
• А6 (углы при развёрнутом угле) → порядок лучей задан явно (∠BOC=40°);
• А16 (сечение параллелепипеда) → размеры/угол 60° в тексте (сечение 12×6=72).
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2013_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2013_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 119;
const N_TASKS = 30;
const PROV = 'ЦТ–2013, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Среди чисел $\sqrt9$; $-9$; $\dfrac19$; $-0{,}9$; $9^{-1}$ выберите число, противоположное числу $9$.`,
opts: mc('$\sqrt9$', '$-9$', '$\dfrac19$', '$-0{,}9$', '$9^{-1}$'),
answer: 'б',
sol: R`Противоположное числу $9$ — это $-9$.` },
{ idx: 2, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 1,
text: R`Прямой круговой цилиндр; $O$ и $O_1$ — центры верхнего и нижнего оснований. Точки $A$ и $B$ лежат на окружности верхнего основания, $C$ и $D$ — на окружности нижнего, причём $A$ находится точно над $D$ (отрезок $AD$ перпендикулярен основаниям). Образующей цилиндра является отрезок:`,
opts: mc('$DB$', '$DC$', '$DO_1$', '$OO_1$', '$AD$'),
answer: 'д',
sol: R`Образующая прямого цилиндра — отрезок поверхности, перпендикулярный основаниям и соединяющий соответствующие точки окружностей. Это отрезок $AD$ ($OO_1$ — ось, а не образующая).` },
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 1,
text: R`Среди точек $B(13;0)$, $T(-7;13)$, $C\left(-\sqrt{13};\sqrt{13}\right)$, $O(0;0)$, $L(0;-13)$ выберите ту, которая принадлежит графику функции $y=13$.`,
opts: mc('$B$', '$T$', '$C$', '$O$', '$L$'),
answer: 'б',
sol: R`Графику $y=13$ принадлежат точки с ординатой $13$. Это $T(-7;13)$.` },
{ idx: 4, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Найдите значение выражения $\left(2\tfrac{7}{12}-2\tfrac{17}{36}\right)\cdot2{,}7-0{,}4$.`,
opts: mc('$0{,}1$', '$-0{,}7$', '$-0{,}1$', '$0{,}3$', '$-1{,}5$'),
answer: 'в',
sol: R`$2\tfrac{7}{12}-2\tfrac{17}{36}=\tfrac{93-89}{36}=\tfrac{4}{36}=\tfrac19$. Тогда $\tfrac19\cdot2{,}7-0{,}4=0{,}3-0{,}4=-0{,}1$.` },
{ idx: 5, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`Одно число меньше другого на $64$, что составляет $16\%$ большего числа. Найдите меньшее число.`,
opts: mc('$800$', '$470$', '$336$', '$464$', '$390$'),
answer: 'в',
sol: R`Большее число $=\dfrac{64}{0{,}16}=400$, меньшее $=400-64=336$.` },
{ idx: 6, type: 'mc', topic: 'planimetry', subtopic: 'plan-angles', diff: 2,
text: R`Угол $AOM$ — развёрнутый ($A$, $O$, $M$ на одной прямой). Лучи $OB$ и $OC$ проведены по одну сторону от прямой $AM$, причём луч $OB$ ближе к лучу $OA$. Известно, что $\angle AOC=107^\circ$, $\angle BOM=113^\circ$. Найдите величину угла $BOC$.`,
opts: mc('$73^\circ$', '$67^\circ$', '$17^\circ$', '$40^\circ$', '$23^\circ$'),
answer: 'г',
sol: R`$\angle AOB=180^\circ-\angle BOM=67^\circ$, поэтому $\angle BOC=\angle AOC-\angle AOB=107^\circ-67^\circ=40^\circ$.` },
{ idx: 7, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Образующая конуса равна $26$ и наклонена к плоскости основания под углом $60^\circ$. Найдите площадь боковой поверхности конуса.`,
opts: mc('$338\pi$', '$338\sqrt3\,\pi$', '$169\pi$', '$260\sqrt3\,\pi$', '$676\pi$'),
answer: 'а',
sol: R`Радиус $r=l\cos60^\circ=26\cdot\tfrac12=13$. Боковая поверхность $=\pi r l=\pi\cdot13\cdot26=338\pi$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Расположите числа $2{,}44$; $\dfrac{18}{7}$; $2{,}(4)$ в порядке возрастания.`,
opts: mc('$2{,}44;\ \dfrac{18}{7};\ 2{,}(4)$', '$2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$', '$\dfrac{18}{7};\ 2{,}44;\ 2{,}(4)$', '$2{,}(4);\ \dfrac{18}{7};\ 2{,}44$', '$2{,}(4);\ 2{,}44;\ \dfrac{18}{7}$'),
answer: 'б',
sol: R`$2{,}44<2{,}(4)=2{,}444\ldots<\dfrac{18}{7}=2{,}571\ldots$, то есть $2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$.` },
{ idx: 9, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`Одна из сторон прямоугольника на $7$ см длиннее другой, а его площадь равна $78$ см². Уравнение, одним из корней которого является длина меньшей стороны прямоугольника, имеет вид:`,
opts: mc('$x^{2}-78x+7=0$', '$x^{2}-7x-78=0$', '$x^{2}+7x+78=0$', '$x^{2}+7x-78=0$', '$x^{2}+78x-7=0$'),
answer: 'г',
sol: R`Если меньшая сторона $x$, то $x(x+7)=78$, то есть $x^{2}+7x-78=0$.` },
{ idx: 10, type: 'mc', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 2,
text: R`Точки $A(-3;3)$ и $B(4;1)$ — вершины квадрата $ABCD$. Периметр квадрата равен:`,
opts: mc('$4\sqrt{17}$', '$2\sqrt{53}$', '$18$', '$15$', '$4\sqrt{53}$'),
answer: 'д',
sol: R`$AB=\sqrt{(4+3)^{2}+(1-3)^{2}}=\sqrt{49+4}=\sqrt{53}$ — сторона квадрата. Периметр $=4\sqrt{53}$.` },
{ idx: 11, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Упростите выражение $\dfrac{11\sqrt{11}+5\sqrt5}{\sqrt{11}+\sqrt5}-\sqrt{55}+\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}$.`,
opts: mc('$\dfrac{1}{\sqrt{11}+\sqrt5}$', '$\sqrt{55}$', '$16$', '$26$', '$\dfrac{5}{\sqrt{11}-\sqrt5}$'),
answer: 'г',
sol: R`$\dfrac{(\sqrt{11})^{3}+(\sqrt5)^{3}}{\sqrt{11}+\sqrt5}=11-\sqrt{55}+5=16-\sqrt{55}$; $\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}=2\sqrt5(\sqrt{11}+\sqrt5)=2\sqrt{55}+10$. Сумма: $(16-\sqrt{55})-\sqrt{55}+(2\sqrt{55}+10)=26$.` },
{ idx: 12, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Решением неравенства $\dfrac{26}{3}-\dfrac{7x^{2}+4x}{7}>\dfrac{2-3x^{2}}{3}$ является промежуток:`,
opts: mc('$(14;+\infty)$', '$(-14;+\infty)$', '$\left(-\infty;\dfrac{1}{14}\right)$', '$(-\infty;14)$', '$\left(\dfrac{1}{14};+\infty\right)$'),
answer: 'г',
sol: R`Умножив на $21$: $182-3(7x^{2}+4x)>7(2-3x^{2})$, то есть $182-21x^{2}-12x>14-21x^{2}$, $182-12x>14$, $x<14$.` },
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Найдите длину средней линии прямоугольной трапеции с острым углом $60^\circ$, у которой бóльшая боковая сторона и бóльшее основание равны $10$.`,
opts: mc('$5\sqrt3$', '$10\sqrt3$', '$15$', '$5$', '$7{,}5$'),
answer: 'д',
sol: R`Проекция наклонной боковой стороны на основание $=10\cos60^\circ=5$, поэтому меньшее основание $=10-5=5$. Средняя линия $=\dfrac{10+5}{2}=7{,}5$.` },
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 3,
text: R`Упростите выражение $\left(5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}\right):(a+b+5c)\cdot2ac$.`,
opts: mc('$a+5c-b$', '$4a^{2}c^{2}$', '$5$', '$a+5c+b$', '$a-5c-b$'),
answer: 'а',
sol: R`$5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}=\dfrac{(a+5c)^{2}-b^{2}}{2ac}=\dfrac{(a+5c-b)(a+5c+b)}{2ac}$. После деления на $(a+b+5c)$ и умножения на $2ac$ получаем $a+5c-b$.` },
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите сумму целых решений неравенства $3(x-5)>(x-5)^{2}$.`,
opts: mc('$13$', '$9$', '$-13$', '$26$', '$-9$'),
answer: 'а',
sol: R`Пусть $u=x-5$: $3u>u^{2}$, $u(u-3)<0$, $0<u<3$, значит $5<x<8$. Целые $6$ и $7$, их сумма $13$.` },
{ idx: 16, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`$ABCDA_1B_1C_1D_1$ — прямоугольный параллелепипед, $AB=12$, $AD=3$. Через середины рёбер $AA_1$ и $BB_1$ проведена плоскость, составляющая угол $60^\circ$ с плоскостью основания $ABCD$. Найдите площадь сечения параллелепипеда этой плоскостью.`,
opts: mc('$72$', '$36\sqrt3$', '$36$', '$18$', '$36\sqrt2$'),
answer: 'а',
sol: R`Сечение — параллелограмм; одна сторона равна $AB=12$, другая проходит через всю глубину $AD=3$ под углом $60^\circ$: её длина $=\dfrac{3}{\cos60^\circ}=6$. Площадь $=12\cdot6=72$.` },
{ idx: 17, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Сумма наибольшего и наименьшего значений функции $y=(3\sin2x+3\cos2x)^{2}$ равна:`,
opts: mc('$8$', '$9$', '$18$', '$36$', '$3$'),
answer: 'в',
sol: R`$y=9(\sin2x+\cos2x)^{2}=9(1+\sin4x)$. Так как $\sin4x\in[-1;1]$, то $y\in[0;18]$. Сумма $0+18=18$.` },
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Корень уравнения $\log_{1{,}6}\dfrac{9-4x}{3x-11}+\log_{1{,}6}\big((9-4x)(3x-11)\big)=0$ (или их сумма, если корней несколько) принадлежит промежутку:`,
opts: mc('$[0;1)$', '$[1;2)$', '$(2;3]$', '$(3;4]$', '$[-1;0)$'),
answer: 'в',
sol: R`Сумма логарифмов равна $\log_{1{,}6}(9-4x)^{2}=0$, поэтому $(9-4x)^{2}=1$, $x=2$ или $x=2{,}5$. Из условия положительности обоих аргументов остаётся $x=2{,}5\in(2;3]$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Автомобиль проехал некоторое расстояние, израсходовав $21$ л топлива; расход составил $9$ л на $100$ км пробега. Затем расход топлива вырос до $12$ л на $100$ км. Сколько литров топлива понадобится автомобилю, чтобы проехать такое же расстояние?`,
answer: '28',
sol: R`Расстояние $=\dfrac{21}{9}\cdot100$ км. При расходе $12$ л/$100$ км нужно $\dfrac{21}{9}\cdot12=28$ л.` },
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 3,
text: R`Решите уравнение $\sqrt{x-5}-\sqrt{(x-5)(x+2)}=0$. В ответ запишите сумму его корней (корень, если он один).`,
answer: '5',
sol: R`ОДЗ: $x\ge5$. $\sqrt{x-5}\,\big(1-\sqrt{x+2}\big)=0$ даёт $x=5$ (второй множитель при $x\ge5$ не равен нулю). Единственный корень $5$.` },
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-triangles', diff: 3,
text: R`Основание остроугольного равнобедренного треугольника равно $10$, а синус противолежащего угла равен $0{,}6$. Найдите площадь треугольника.`,
answer: '75',
sol: R`Острый противолежащий угол $\alpha$: $\sin\alpha=0{,}6$, $\cos\alpha=0{,}8$. По теореме косинусов $10^{2}=2b^{2}(1-\cos\alpha)=0{,}4b^{2}$, $b^{2}=250$. Площадь $=\tfrac12 b^{2}\sin\alpha=\tfrac12\cdot250\cdot0{,}6=75$.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-systems', diff: 4,
text: R`Пусть $(x;y)$ — целочисленное решение системы уравнений $\begin{cases}4y+x=-14,\\ 4y^{2}-4xy+x^{2}=16.\end{cases}$ Найдите сумму $x+y$.`,
answer: '-5',
sol: R`Второе уравнение: $(x-2y)^{2}=16$, $x-2y=\pm4$. С $x=-14-4y$ целое решение даёт $y=-3$, $x=-2$. Тогда $x+y=-5$.` },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите наибольшее целое решение неравенства $2^{3x-32}\cdot11^{x-6}>22^{2x-19}$.`,
answer: '12',
sol: R`$22^{2x-19}=2^{2x-19}\cdot11^{2x-19}$, поэтому неравенство равносильно $\left(\tfrac{2}{11}\right)^{x-13}>1$, то есть $x-13<0$, $x<13$. Наибольшее целое — $12$.` },
{ idx: 24, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
text: R`Найдите количество корней уравнения $32\sin2x+8\cos4x=23$ на промежутке $\left[-\pi;\dfrac{3\pi}{4}\right]$.`,
answer: '4',
sol: R`Через $\cos4x=1-2\sin^{2}2x$ получаем $16\sin^{2}2x-32\sin2x+15=0$, откуда $\sin2x=0{,}75$. На указанном промежутке ($2x\in[-2\pi;\tfrac{3\pi}{2}]$) уравнение имеет $4$ корня.` },
{ idx: 25, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`Геометрическая прогрессия со знаменателем $5$ содержит $10$ членов. Сумма всех членов прогрессии равна $24$. Найдите сумму всех членов прогрессии с чётными номерами.`,
answer: '20',
sol: R`Каждый чётный член в $5$ раз больше предыдущего нечётного, поэтому сумма чётных в $5$ раз больше суммы нечётных. Если сумма нечётных равна $s$, то $s+5s=24$, $s=4$, и сумма членов с чётными номерами равна $5s=20$.` },
{ idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-modulus', diff: 5,
text: R`Найдите сумму корней уравнения $\big|(x-1)(x-6)\big|\cdot\big(|x+2|+|x-8|+|x-3|\big)=11(x-1)(6-x)$.`,
answer: '13',
sol: R`Правая часть неотрицательна лишь при $1\le x\le6$; на этом отрезке $|(x-1)(x-6)|=(x-1)(6-x)$. Уравнение даёт $(x-1)(6-x)\big(S-11\big)=0$, где $S=|x+2|+|x-8|+|x-3|=10+|x-3|$. Корни: $x=1,\ 6$ (множитель $0$) и $|x-3|=1$, то есть $x=2,\ 4$. Сумма $1+2+4+6=13$.` },
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`Из города $A$ в город $B$, расстояние между которыми $100$ км, одновременно выезжают два автомобиля. Скорость первого автомобиля на $10$ км/ч больше скорости второго, но в пути он делает остановку на $50$ мин. Найдите наибольшее значение скорости (в км/ч) первого автомобиля, при движении с которой он прибудет в $B$ не позже второго.`,
answer: '40',
sol: R`Пусть скорость второго $v$. Условие $\dfrac{100}{v+10}+\dfrac56\le\dfrac{100}{v}$ приводит к $\dfrac56\le\dfrac{1000}{v(v+10)}$, то есть $v(v+10)\le1200$, $v\le30$. Наибольшая скорость первого $=30+10=40$.` },
{ idx: 28, type: 'open', topic: 'planimetry', subtopic: 'plan-circles', diff: 5,
text: R`Из точки $A$ проведены к окружности радиуса $\dfrac43$ касательная $AB$ ($B$ — точка касания) и секущая $AC$, проходящая через центр окружности и пересекающая её в точках $D$ и $C$. Найдите площадь $S$ треугольника $ABC$, если длина секущей $AC$ в $3$ раза больше длины касательной. В ответ запишите $5S$.`,
answer: '6',
sol: R`$AB^{2}=AO^{2}-r^{2}$ и $AC=AO+r=3\,AB$ дают $AB=\tfrac{3r}{4}=1$, $AO=\tfrac53$, $AC=3$. В координатах $B=(0{,}6;0{,}8)$, высота из $B$ к $AC$ равна $0{,}8$, площадь $=\tfrac12\cdot3\cdot0{,}8=1{,}2$. Тогда $5S=6$.` },
{ idx: 29, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Если $\cos(\alpha+14^\circ)=\dfrac35$ и $0<\alpha+14^\circ<90^\circ$, то значение выражения $15\sqrt2\,\cos(\alpha+59^\circ)$ равно … .`,
answer: '-3',
sol: R`$\cos(\alpha+59^\circ)=\cos\big((\alpha+14^\circ)+45^\circ\big)=\tfrac{\sqrt2}{2}\big(\tfrac35-\tfrac45\big)=-\tfrac{\sqrt2}{10}$ (здесь $\sin(\alpha+14^\circ)=\tfrac45$). Тогда $15\sqrt2\cdot\left(-\tfrac{\sqrt2}{10}\right)=-3$.` },
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 5,
text: R`Решите уравнение $\dfrac{30x^{2}}{x^{4}+25}=x^{2}+2\sqrt5\,x+8$. В ответ запишите значение выражения $x\cdot|x|$, где $x$ — корень уравнения.`,
answer: '-5',
sol: R`Левая часть $\le3$ (так как $x^{4}+25\ge10x^{2}$), правая часть $=(x+\sqrt5)^{2}+3\ge3$. Равенство возможно лишь при $x=-\sqrt5$. Тогда $x\cdot|x|=-\sqrt5\cdot\sqrt5=-5$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2013_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2013_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2013».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+350
View File
@@ -0,0 +1,350 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2017_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2017, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (В1 — на соответствие). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2017\CT-2017.pdf
(ответы — отдельный файл «Ответы ЦТ 2017.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B6=56, B8=-143, B11=121, B12=115. variant=118 (закрывает пробел
между ЦТ-2016 и ЦТ-2018). Прогнан через дедуп-гейт (check_variant_dups.js) — без повторов.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (вращение прямоугольников) → размеры сторона-ось/смежная даны числами (квадрат-сечение ⟺ ось=2·смежная → 3,5);
• А3 (график движения) → путь на участке BC задан числами (52 км/ч);
• А9 (треугольник по рисунку) → явно: BM — биссектриса угла B, AM/MC=AB/BC → 13,8;
• А11 (фигура на сетке) → площадь фигуры дана числом ($18$ см² = 28 % трапеции → 64 2/7);
• А14 (выбор параболы) → вершина/точка/направление ветвей в тексте.
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2017_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2017_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 118;
const N_TASKS = 30;
const PROV = 'ЦТ–2017, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Прямоугольник вращают вокруг указанной стороны (оси), образуя цилиндр. Осевым сечением цилиндра должен быть квадрат. Укажите номера прямоугольников (ось $\times$ смежная сторона): $1)\ 8\times8$; $\ 2)\ 8\times16$; $\ 3)\ 8\times4$; $\ 4)\ 4\times8$; $\ 5)\ 16\times8$.`,
opts: mc('$2,\ 3$', '$1,\ 5$', '$3,\ 5$', '$2,\ 4$', '$1,\ 3,\ 5$'),
answer: 'в',
sol: R`Осевое сечение — прямоугольник «ось $\times$ диаметр $=$ ось $\times2\cdot$смежная». Это квадрат, когда ось $=2\cdot$смежная: для $8\times4$ ($8=2\cdot4$) и $16\times8$ ($16=2\cdot8$). Значит $3$ и $5$.` },
{ idx: 2, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Выразите $737$ см $8$ мм в метрах с точностью до сотых.`,
opts: mc('$0{,}74$ м', '$7{,}37$ м', '$7{,}378$ м', '$7{,}38$ м', '$73{,}78$ м'),
answer: 'г',
sol: R`$737$ см $8$ мм $=7{,}378$ м $\approx7{,}38$ м.` },
{ idx: 3, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
text: R`По графику движения автомобиля на участке $BC$ путь изменился с $52$ км до $104$ км за $1$ ч. Найдите скорость движения на участке $BC$.`,
opts: mc('$26$ км/ч', '$52$ км/ч', '$78$ км/ч', '$104$ км/ч', '$60$ км/ч'),
answer: 'б',
sol: R`$v=\dfrac{104-52}{1}=52$ км/ч.` },
{ idx: 4, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Выразите $a$ из равенства $\dfrac{3}{2b+1}=\dfrac{6}{a-b}$.`,
opts: mc('$a=5b+2$', '$a=5b-2$', '$a=15b-6$', '$a=15b+6$', '$a=3b+1$'),
answer: 'а',
sol: R`$3(a-b)=6(2b+1)$, $a-b=4b+2$, $a=5b+2$.` },
{ idx: 5, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Значение выражения $8\sqrt3+\dfrac18\sqrt{192}$ равно:`,
opts: mc('$16\sqrt3$', '$\sqrt{195}$', '$\dfrac{65\sqrt{195}}{8}$', '$\dfrac{6\sqrt3}{8}$', '$9\sqrt3$'),
answer: 'д',
sol: R`$\sqrt{192}=8\sqrt3$, поэтому $\dfrac18\cdot8\sqrt3=\sqrt3$ и $8\sqrt3+\sqrt3=9\sqrt3$.` },
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 1,
text: R`Последовательность $(a_n)$ задана формулой $a_n=3n^{2}-8n+9$. Второй член этой последовательности равен:`,
opts: mc('$12$', '$-16$', '$5$', '$16$', '$6$'),
answer: 'в',
sol: R`$a_2=3\cdot4-16+9=5$.` },
{ idx: 7, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 2,
text: R`Значение выражения $7\cos^{2}34^\circ+10\sin30^\circ+7\sin^{2}34^\circ$ равно:`,
opts: mc('$12$', '$17$', '$24$', '$7+10\sqrt3$', '$14+5\sqrt3$'),
answer: 'а',
sol: R`$7(\cos^{2}34^\circ+\sin^{2}34^\circ)+10\cdot\tfrac12=7+5=12$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Среди утверждений укажите номер верного.<br>$1)$ число $451$ кратно числу $5$; $\ 2)$ число $9$ кратно числу $35$; $\ 3)$ число $2$ кратно числу $14$; $\ 4)$ число $116$ кратно числу $1$; $\ 5)$ число $43$ кратно числу $0$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'г',
sol: R`Любое целое кратно $1$, поэтому $116$ кратно $1$ — верно (утверждение 4). Остальные неверны.` },
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`В треугольнике $ABC$ отрезок $BM$ — биссектриса угла $B$ ($M$ на $AC$). Известно, что $AC=32$, $AM=12$, $BC=23$. Найдите длину стороны $AB$.`,
opts: mc('$10{,}2$', '$14{,}6$', '$13{,}8$', '$13{,}5$', '$10{,}4$'),
answer: 'в',
sol: R`Биссектриса делит сторону в отношении прилежащих сторон: $\dfrac{AM}{MC}=\dfrac{AB}{BC}$. $MC=32-12=20$, поэтому $AB=BC\cdot\dfrac{AM}{MC}=23\cdot\dfrac{12}{20}=13{,}8$.` },
{ idx: 10, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Результат упрощения выражения $\sqrt{(2x-4{,}6)^{2}}+4{,}6$ при $-1<x<1$ имеет вид:`,
opts: mc('$9{,}2-2x$', '$-2x-9{,}2$', '$2x+9{,}2$', '$2x$', '$-2x$'),
answer: 'а',
sol: R`При $-1<x<1$ имеем $2x-4{,}6<0$, поэтому $\sqrt{(2x-4{,}6)^{2}}=4{,}6-2x$, и сумма $=(4{,}6-2x)+4{,}6=9{,}2-2x$.` },
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Площадь фигуры на клетчатой бумаге равна $18$ см² и составляет 28 % площади некоторой трапеции. Найдите площадь трапеции (в квадратных сантиметрах).`,
opts: mc('$504$', '$64\tfrac27$', '$35$', '$72\tfrac34$', '$155\tfrac59$'),
answer: 'б',
sol: R`$\dfrac{18}{0{,}28}=\dfrac{1800}{28}=\dfrac{450}{7}=64\tfrac27$ см².` },
{ idx: 12, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Определите остроугольный треугольник по длинам его сторон: $\triangle ABC$ ($8;15;17$), $\triangle MNK$ ($4;6;8$), $\triangle BDC$ ($3;4;5$), $\triangle FBC$ ($7;8;9$), $\triangle CDE$ ($5;11;13$).`,
opts: mc('$\triangle ABC$', '$\triangle MNK$', '$\triangle BDC$', '$\triangle FBC$', '$\triangle CDE$'),
answer: 'г',
sol: R`Треугольник остроугольный, если квадрат большей стороны меньше суммы квадратов двух других. Только для $\triangle FBC$: $9^{2}=81<7^{2}+8^{2}=113$. ($ABC$ и $BDC$ прямоугольные, $MNK$ и $CDE$ тупоугольные.)` },
{ idx: 13, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`Купили $m$ ручек по цене $2$ руб $3$ коп за штуку и $178$ тетрадей по цене $a$ коп за штуку. Составьте выражение, определяющее стоимость покупки (в рублях).`,
opts: mc('$2{,}03m+178a$', '$2{,}03m+1{,}78a$', '$2{,}3m+1{,}78a$', '$2{,}3m+17{,}8a$', '$2{,}03m+17{,}8a$'),
answer: 'б',
sol: R`$2$ руб $3$ коп $=2{,}03$ руб, $178$ тетрадей по $a$ коп $=178a$ коп $=1{,}78a$ руб. Итого $2{,}03m+1{,}78a$.` },
{ idx: 14, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Парабола проходит через точку $(0;3)$, имеет вершину в точке $(-1;1)$, ветви направлены вверх. Укажите номер её уравнения.<br>$1)\ y=x^{2}+4x+3$; $\ 2)\ y=x^{2}-4x-3$; $\ 3)\ y=2x^{2}+4x+3$; $\ 4)\ y=2x^{2}+4x-3$; $\ 5)\ y=2x^{2}-4x+3$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`У $y=2x^{2}+4x+3$ вершина в точке $(-1;1)$ ($x=-\tfrac{4}{4}=-1$, $y=2-4+3=1$), $y(0)=3$, ветви вверх. Это уравнение 3.` },
{ idx: 15, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`$ABCDA_1B_1C_1D_1$ — куб. Точки $M$ и $N$ — середины рёбер $AD$ и $DC$, точка $K$ на ребре $A_1D_1$ с $KA_1:KD_1=1:3$. Сечением куба плоскостью, проходящей через точки $M$, $N$ и $K$, является:`,
opts: mc('восьмиугольник', 'треугольник', 'четырёхугольник', 'пятиугольник', 'шестиугольник'),
answer: 'в',
sol: R`Плоскость отсекает ребро $DD_1$ (оба конца по одну сторону) и пересекает четыре ребра: $AD$ (точка $M$), $DC$ ($N$), $A_1D_1$ ($K$) и $D_1C_1$. Четыре точки — сечение является четырёхугольником.` },
{ idx: 16, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите сумму наименьшего и наибольшего целых решений двойного неравенства $-448{,}9<2{,}9+9x<23{,}6$.`,
opts: mc('$-52$', '$-47$', '$-49$', '$-48$', '$-53$'),
answer: 'г',
sol: R`$-451{,}8<9x<20{,}7$, то есть $-50{,}2<x<2{,}3$. Целые от $-50$ до $2$; сумма наименьшего и наибольшего $-50+2=-48$.` },
{ idx: 17, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 3,
text: R`Через точку $A$ высоты $SO$ конуса проведена плоскость, параллельная основанию. Определите, во сколько раз площадь основания конуса больше площади полученного сечения, если $SA:AO=2:3$.`,
opts: mc('$6\tfrac14$', '$7\tfrac14$', '$2\tfrac14$', '$1\tfrac12$', '$2\tfrac12$'),
answer: 'а',
sol: R`Сечение подобно основанию с коэффициентом $\dfrac{SA}{SO}=\dfrac{2}{5}$. Отношение площадей $\left(\dfrac{SO}{SA}\right)^{2}=\left(\dfrac52\right)^{2}=6\tfrac14$.` },
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
text: R`Укажите (в градусах) наименьший положительный корень уравнения $\cos(6x-72^\circ)=\dfrac{\sqrt3}{2}$.`,
opts: mc('$5^\circ$', '$102^\circ$', '$17^\circ$', '$42^\circ$', '$7^\circ$'),
answer: 'д',
sol: R`$6x-72^\circ=\pm30^\circ+360^\circ k$, поэтому $x=17^\circ+60^\circ k$ или $x=7^\circ+60^\circ k$. Наименьший положительный корень $7^\circ$.` },
// ── Часть B: В1–В12 ──────────────────────────────────────────────────────
{ idx: 19, type: 'long', topic: 'functions', subtopic: 'fn-graphs', diff: 3,
text: R`Для начала каждого из предложений А–В подберите его окончание $1$$6$.<br>А) Окружность с центром $(-8;-2)$ и радиусом $4$ задаётся уравнением …<br>Б) Уравнение прямой, проходящей через точку $(-8;2)$ параллельно прямой $y=\tfrac14 x$, имеет вид …<br>В) График обратной пропорциональности, проходящий через точку $\left(\tfrac12;-\tfrac12\right)$, задаётся уравнением …<br>Окончания: $1)\ xy=2$; $\ 2)\ (x-8)^{2}+(y-2)^{2}=4$; $\ 3)\ -\tfrac14 x+y=4$; $\ 4)\ (x+8)^{2}+(y+2)^{2}=16$; $\ 5)\ 4xy+1=0$; $\ 6)\ \tfrac14 x+y=2$.`,
answer: 'А4Б3В5',
ansShow: 'А4Б3В5',
sol: R`А) $(x+8)^{2}+(y+2)^{2}=16$ (окончание 4). Б) $y-2=\tfrac14(x+8)$, то есть $-\tfrac14 x+y=4$ (окончание 3). В) $y=\tfrac{k}{x}$ через $\left(\tfrac12;-\tfrac12\right)$ даёт $k=-\tfrac14$, то есть $xy=-\tfrac14$, $4xy+1=0$ (окончание 5). Ответ: А4Б3В5.` },
{ idx: 20, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Конфеты в коробке упаковываются рядами, причём количество конфет в каждом ряду на $4$ больше количества рядов. Дизайн коробки изменили: добавили $2$ ряда, а в каждом ряду — по $1$ конфете. В результате количество конфет в коробке увеличилось на $25$. Сколько конфет упаковывалось в коробку первоначально?`,
answer: '45',
sol: R`Пусть рядов $r$, в ряду $r+4$. Тогда $(r+2)(r+5)-r(r+4)=3r+10=25$, $r=5$. Первоначально $5\cdot9=45$ конфет.` },
{ idx: 21, type: 'open', topic: 'expressions', subtopic: 'expr-polynomials', diff: 3,
text: R`Известно, что при $a$, равном $-2$ и $4$, значение выражения $4a^{3}+3a^{2}-ab+c$ равно нулю. Найдите значение выражения $b+c$.`,
answer: '-34',
sol: R`При $a=-2$: $-20+2b+c=0$; при $a=4$: $304-4b+c=0$. Вычитая, $6b=324$, $b=54$, тогда $c=-88$, и $b+c=-34$.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 4,
text: R`Найдите произведение корней (корень, если он единственный) уравнения $x^{2}-5x-3=4\sqrt{x^{2}-5x+9}$.`,
answer: '-27',
sol: R`Пусть $u=x^{2}-5x$. Тогда $u-3=4\sqrt{u+9}$ ($u\ge3$); возведя в квадрат, $u^{2}-22u-135=0$, $u=27$. Из $x^{2}-5x-27=0$ произведение корней $-27$.` },
{ idx: 23, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`В параллелограмме с острым углом $45^\circ$ точка пересечения диагоналей удалена от прямых, содержащих неравные стороны, на расстояния $\dfrac{7\sqrt2}{2}$ и $2$. Найдите площадь параллелограмма.`,
answer: '56',
sol: R`Расстояние от центра до стороны — половина высоты. Высоты $H_1=7\sqrt2$ и $H_2=4$. Из $H=l\sin45^\circ$: стороны $b=14$, $a=4\sqrt2$. Площадь $=b\cdot H_2=14\cdot4=56$.` },
{ idx: 24, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Пусть $x_0$ — наибольший корень уравнения $\log_2^{2}\dfrac{x}{32}+4\log_2 x-52=0$. Найдите значение выражения $7\sqrt[3]{x_0}$.`,
answer: '56',
sol: R`Пусть $t=\log_2 x$. Тогда $(t-5)^{2}+4t-52=0$, $t^{2}-6t-27=0$, $t=9$ или $t=-3$. Наибольший корень $x_0=2^{9}=512$, и $7\sqrt[3]{512}=7\cdot8=56$.` },
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Решите неравенство $\left(\dfrac{1}{5-\sqrt{24}}\right)^{x+6}\ge\left(5-\sqrt{24}\right)^{\frac{4x+25}{x+4}}$. В ответ запишите сумму целых решений, принадлежащих промежутку $[-20;-2]$.`,
answer: '-12',
sol: R`Так как $\dfrac{1}{5-\sqrt{24}}=(5-\sqrt{24})^{-1}$ и $0<5-\sqrt{24}<1$, неравенство равносильно $-(x+6)\le\dfrac{4x+25}{x+4}$, что приводит к $\dfrac{(x+7)^{2}}{x+4}\ge0$. Решение: $x>-4$ или $x=-7$. На $[-20;-2]$ целые $-7,-3,-2$; их сумма $-12$.` },
{ idx: 26, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
text: R`Найдите увеличенное в $9$ раз произведение абсцисс точек пересечения прямой $y=12$ и графика нечётной функции, которая определена на $(-\infty;0)\cup(0;+\infty)$ и при $x>0$ задаётся формулой $y=2^{3x-8}-20$.`,
answer: '-143',
sol: R`При $x>0$: $2^{3x-8}-20=12$, $2^{3x-8}=32$, $x=\tfrac{13}{3}$. По нечётности при $x<0$ получаем $x=-\tfrac{11}{3}$. Произведение $\tfrac{13}{3}\cdot\left(-\tfrac{11}{3}\right)=-\tfrac{143}{9}$; увеличенное в $9$ раз — $-143$.` },
{ idx: 27, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 4,
text: R`Найдите площадь полной поверхности прямой треугольной призмы, описанной около шара, если площадь основания призмы равна $7{,}5$.`,
answer: '45',
sol: R`У описанной около шара призмы высота $h=2r$, а в основании вписана окружность радиуса $r$, поэтому площадь основания $S=rp=7{,}5$. Боковая поверхность $=2p\cdot2r=4\cdot rp=30$. Полная $=2\cdot7{,}5+30=45$.` },
{ idx: 28, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Найдите произведение наибольшего целого решения на количество целых решений неравенства $\dfrac{16}{6+|24-x|}>|24-x|$.`,
answer: '75',
sol: R`Пусть $u=|24-x|\ge0$. Тогда $16>u(6+u)$, $u^{2}+6u-16<0$, $0\le u<2$. Значит $|24-x|<2$, то есть $22<x<26$. Целые $23,24,25$ ($3$ решения); наибольшее $25$. Произведение $25\cdot3=75$.` },
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`Первые члены арифметической и геометрической прогрессий одинаковы и равны $1$, третьи члены также одинаковы, а вторые отличаются на $18$. Найдите шестой член арифметической прогрессии, если все члены обеих прогрессий положительны.`,
answer: '121',
sol: R`$1+2d=q^{2}$ и $\left|\tfrac{q^{2}+1}{2}-q\right|=\tfrac{(q-1)^{2}}{2}=18$, откуда $q=7$ (положительные члены), $d=24$. Тогда $a_6=1+5\cdot24=121$.` },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`$ABCDA_1B_1C_1D_1$ — прямая четырёхугольная призма, объём которой равен $960$. Основанием призмы является параллелограмм $ABCD$. Точки $M$ и $N$ принадлежат рёбрам $A_1D_1$ и $C_1D_1$ так, что $A_1M:A_1D_1=1:2$, $D_1N:NC_1=1:3$. Отрезки $A_1N$ и $B_1M$ пересекаются в точке $K$. Найдите объём пирамиды $SB_1KNC_1$, если $S\in B_1D$ и $B_1S:SD=3:1$.`,
answer: '115',
sol: R`Координатным методом (положения $K$, $N$ на верхней грани и точки $S$ на диагонали $B_1D$) объём пирамиды составляет $\dfrac{23}{192}$ объёма призмы: $\dfrac{23}{192}\cdot960=115$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2017_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2017_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2017».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+63 -6
View File
@@ -1,7 +1,10 @@
const db = require('../db/db');
const fs = require('fs');
const path = require('path');
const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit');
const { purgeAccessFor } = require('../services/contentAccess');
const sysReset = require('../services/systemReset');
/* ── Prepared statements ──────────────────────────────────────────────── */
const stmts = {
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
function getAllSessions(req, res) {
const { subject, user_id } = req.query;
const { subject, user_id, status } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
const offset = Math.max(0, Number(req.query.offset) || 0);
const where = ['ts.status = \'completed\''];
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
const where = [];
const params = [];
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
where.push('ts.status = ?'); params.push(status);
}
if (subject) { where.push('s.slug = ?'); params.push(subject); }
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
@@ -314,7 +322,7 @@ function getAllSessions(req, res) {
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
WHERE ${where.join(' AND ')}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(...params);
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant', 'sim_builder', 'quantik'];
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
@@ -586,6 +594,56 @@ function updateFreeStudentFeatures(req, res) {
res.json({ ok: true });
}
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
function getResetPlan(req, res) {
try {
const plan = sysReset.classify(db);
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
} catch (e) {
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
}
}
/* ── POST /api/admin/reset-system ──────────────────────────────────────
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
стирает остальных пользователей + активность, переназначает контент. */
function resetSystem(req, res) {
const confirm = (req.body && req.body.confirm) || '';
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
}
const keptId = req.user.id;
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
let backupName = null;
try {
const dbPath = db._path;
if (!dbPath) throw new Error('путь к БД неизвестен');
const backupsDir = path.join(path.dirname(dbPath), 'backups');
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
const d = new Date();
const p2 = n => String(n).padStart(2, '0');
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
backupName = `learnspace-prereset-${ts}.db`;
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
} catch (e) {
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
}
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
let summary;
try {
summary = sysReset.runReset(db, keptId);
} catch (e) {
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
}
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
res.json({ ok: true, backup: backupName, ...summary });
}
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
function getAuditLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
@@ -659,8 +717,6 @@ function clearSecurityLog(req, res) {
/* ── GET /api/admin/health ─────────────────────────────────────────── */
const os = require('os');
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { monitorEventLoopDelay } = require('perf_hooks');
const sse = require('../sse');
@@ -1157,6 +1213,7 @@ module.exports = {
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getResetPlan, resetSystem,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
@@ -2,6 +2,7 @@ const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants');
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
const VALID_ASSIGN_MODES = SESSION_MODES;
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
function assignmentRowsForUser(uid) {
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
@@ -267,6 +268,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -295,6 +297,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -334,8 +337,78 @@ function myAssignments(req, res) {
// Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read;
}
return rows;
}
res.json(rows);
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
res.json(assignmentRowsForUser(req.user.id));
}
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
function classOutstanding(req, res) {
const cid = req.params.id;
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(cid);
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
const subRows = db.prepare(`
SELECT s.assignment_id, s.student_id, s.status
FROM submissions s
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
`).all(cid);
const subMap = new Map();
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
const now = Date.now();
const cidNum = Number(cid);
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
const students = members.map(m => {
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
const rows = assignmentRowsForUser(m.id).filter(r =>
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
);
const pending = [];
for (const r of rows) {
const t = AssignmentUtils.type(r);
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
let status = overdue ? 'overdue' : 'not_started';
if (st === 'revision') status = 'revision'; // вернули на доработку
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
pending.push({
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
status, is_homework: r.is_homework ? 1 : 0,
scope: r.class_id === cidNum ? 'class' : 'direct',
});
}
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
pending.forEach(p => { counts[p.status]++; });
return { id: m.id, name: m.name, email: m.email, pending, counts };
});
const summary = {
students_total: members.length,
debtors: students.filter(s => s.counts.total > 0).length,
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
};
res.json({ className: cls.name, summary, students });
}
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
@@ -732,6 +805,7 @@ module.exports = {
deleteAssignment,
teacherAssignments,
myAssignments,
classOutstanding,
startAssignment,
assignmentResults,
assignmentQuestionStats,
@@ -41,6 +41,15 @@ function createSession(req, res) {
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
if (class_id) sse.emitToClass(class_id, payload);
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
sse.emit(teacher.id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json(session);
}
@@ -74,6 +83,17 @@ function endSession(req, res) {
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'ended', sessionId };
if (session.class_id) sse.emitToClass(session.class_id, payload);
else {
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const r of invited) sse.emit(r.user_id, payload);
}
sse.emit(session.teacher_id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json({ ok: true });
}
@@ -0,0 +1,29 @@
'use strict';
/* clientErrorController — приём ошибок из браузера пользователя.
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
Запись не должна ронять запрос — любые сбои глушим. */
const db = require('../db/db');
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
function report(req, res) {
const b = req.body || {};
const message = (clamp(b.message, MAX_MSG) || '').trim();
if (!message) return res.status(400).json({ error: 'message required' });
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
const route = clamp(b.url || b.route, MAX_ROUTE);
let stack = clamp(b.stack, MAX_STACK);
// если стека нет — собираем источник:строка:колонка
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
try {
db.prepare(
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
).run('client', message, stack, route, kind, req.user.id);
} catch { /* лог не должен ломать ответ */ }
res.json({ ok: true });
}
module.exports = { report };
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
}
function onLabExperiment(userId, reactionsDiscovered) {
if (!isGamificationEnabled()) return; // master kill-switch
stmts.incrLabExp.run(userId);
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
awardXP(userId, 15, 'lab_experiment');
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
}
function updateChallenges(userId, score, total, subjectSlug, topicId) {
if (!isGamificationEnabled()) return; // master kill-switch
const week = _currentWeek();
const pct = total > 0 ? Math.round(score / total * 100) : 0;
const challenges = stmts.getOpenChallenges.all(userId, week);
+22 -13
View File
@@ -7,13 +7,16 @@ function list(req, res) {
const args = [];
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
const isStudent = role === 'student' || role === 'free_student';
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
if (isStudent) { where += ' AND t.available_to_students = 1'; }
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
let rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
u.name AS creator_name,
COUNT(tq.question_id) AS question_count
FROM tests t
@@ -22,18 +25,19 @@ function list(req, res) {
WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args);
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
res.json(rows);
}
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
res.status(201).json({ id: r.lastInsertRowid });
}
@@ -76,13 +80,18 @@ function getOne(req, res) {
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
tl !== undefined ? tl : t.time_limit,
t.id);
const b = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
res.json({ ok: true });
}
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { pushNotif } = require('../utils/notifications');
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
const STATUS_LABEL = {
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
done: 'Готово', declined: 'Отклонено',
};
function clampStr(v, max) {
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
}
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
function list(req, res) {
const isAdmin = req.user.role === 'admin';
const { status, category } = req.query;
const where = [];
const args = [];
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
const sql = `
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
w.created_at, w.updated_at,
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
0 AS _pad
FROM wishes w
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
w.updated_at DESC`;
const rows = db.prepare(sql).all(...args);
let counts = null;
if (isAdmin) {
counts = {};
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
}
res.json({ wishes: rows, counts, isAdmin });
}
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
function create(req, res) {
const title = clampStr(req.body?.title, 200);
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
const body = clampStr(req.body?.body, 4000);
let category = String(req.body?.category || 'other');
if (!CATEGORIES.includes(category)) category = 'other';
const info = db.prepare(
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
).run(req.user.id, title, body || null, category);
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
res.status(201).json(row);
}
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
function update(req, res) {
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const fields = [];
const args = [];
let newStatus = null;
if (req.body?.status !== undefined) {
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
if (req.body.status !== wish.status) newStatus = req.body.status;
fields.push('status = ?'); args.push(req.body.status);
}
if (req.body?.admin_note !== undefined) {
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
}
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
fields.push("updated_at = datetime('now')");
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
// Уведомить автора при смене статуса (durable + SSE).
if (newStatus && wish.user_id !== req.user.id) {
try {
pushNotif(wish.user_id, 'wish_update',
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
} catch { /* notif не критичен */ }
}
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
}
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
function remove(req, res) {
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const isAdmin = req.user.role === 'admin';
const isOwner = wish.user_id === req.user.id;
if (!isAdmin && !(isOwner && wish.status === 'new'))
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
res.json({ ok: true });
}
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
+1
View File
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
};
};
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
module.exports = db;
@@ -0,0 +1,4 @@
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
+15
View File
@@ -0,0 +1,15 @@
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
+17 -1
View File
@@ -119,6 +119,22 @@ function parentAuth(req, res, next) {
}
}
/**
* requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям
* ученика (student/free_student); учитель и админ проходят всегда.
* Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент,
* материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать
* доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false).
*/
function requirePermissionForStudents(key) {
const guard = requirePermission(key);
return (req, res, next) => {
const r = req.user?.role;
if (r === 'student' || r === 'free_student') return guard(req, res, next);
return next();
};
}
/* Alias: requireAuth = authMiddleware */
const requireAuth = authMiddleware;
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
next();
}
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
+59
View File
@@ -115,6 +115,28 @@ const PERMISSIONS = {
label: 'Управление геймификацией',
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
},
'classroom.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Вести онлайн-уроки',
desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса',
requireConfirmOff: true,
},
'livequiz.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Запускать живые викторины',
desc: 'Создавать и проводить синхронные викторины в реальном времени',
},
'simbuilder.use': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Конструктор симуляций',
desc: 'Создавать и редактировать собственные интерактивные симуляции',
requireConfirmOff: true,
},
'flashcards.manage': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Общие колоды флеш-карт',
desc: 'Создавать и раздавать общие колоды флеш-карт классам',
},
/* ── Student (also applies to free_student — same keys, same defaults) ── */
'tests.free': {
@@ -160,6 +182,38 @@ const PERMISSIONS = {
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
requires: ['simulations.access'],
},
'homework.submit': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сдавать домашние задания',
desc: 'Загружать работы и пересдавать домашние задания',
},
'materials.save': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сохранять материалы',
desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»',
},
'assistant.use': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'ИИ-ассистент',
desc: 'Задавать вопросы ИИ-ассистенту «Квантик»',
},
'flashcards.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Раздел флеш-карт доступен роли',
desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»',
requireConfirmOff: true,
},
'exam.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Подготовка к экзаменам доступна роли',
desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»',
requireConfirmOff: true,
},
'games.play': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Учебные игры',
desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)',
},
};
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
@@ -169,15 +223,20 @@ const GROUP = {
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
// student
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
'profile.edit': 'Профиль',
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
'flashcards.access': 'Контент', 'exam.access': 'Контент',
};
/**
+4
View File
@@ -13,6 +13,10 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
/* Everything below is admin-only */
router.use(requireRole('admin'));
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
router.get('/assistant', ctrl.getAssistant);
router.put('/assistant', ctrl.saveAssistant);
router.post('/assistant/test', ctrl.testAssistant);
+3 -3
View File
@@ -2,7 +2,7 @@
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
* 'pet' навешивается при монтировании в server.js. */
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/assistantController');
@@ -16,8 +16,8 @@ router.get('/context', ctrl.getContext);
router.post('/seen', ctrl.markSeen);
router.post('/dismiss', ctrl.dismiss);
router.patch('/settings', ctrl.setSettings);
router.post('/ask', askLimiter, ctrl.ask);
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
router.post('/feedback', ctrl.feedback);
router.get('/memory', ctrl.getMemory);
router.delete('/memory', ctrl.clearMemory);
+1
View File
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
+2 -2
View File
@@ -2,7 +2,7 @@ const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const c = require('../controllers/classroomController');
@@ -47,7 +47,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
router.get('/class/:classId/history', ...auth, c.getClassHistory);
// Session lifecycle
router.post('/', ...teacher, c.createSession);
router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
router.get('/online-students', ...teacher, c.getOnlineStudents);
router.get('/my/session', ...auth, c.getMySession);
router.get('/class/:classId/active', ...auth, c.getActiveSession);
+11
View File
@@ -0,0 +1,11 @@
'use strict';
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/clientErrorController');
router.use(authMiddleware);
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
module.exports = router;
+3 -3
View File
@@ -5,7 +5,7 @@
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController');
@@ -22,9 +22,9 @@ router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related);
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
+8 -1
View File
@@ -1,10 +1,13 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const access = require('../services/contentAccess');
router.use(authMiddleware);
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
router.use(requirePermissionForStudents('exam.access'));
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
Админ/учитель проходят всегда; ученик — только при наличии правила. */
@@ -57,6 +60,10 @@ const VARIANT_LABEL = {
115: 'ЦТ-2019',
116: 'ЦТ-2020',
117: 'ЦТ-2021',
118: 'ЦТ-2017',
119: 'ЦТ-2013',
120: 'ЦТ-2012',
121: 'ЦТ-2011',
},
};
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
+6 -3
View File
@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const fc = require('../controllers/flashcardController');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
/* ── multer для картинок карточек ───────────────────────────────────────
@@ -30,6 +30,9 @@ const fcUpload = multer({
});
router.use(authMiddleware);
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
// учитель/админ проходят всегда (создают и раздают колоды).
router.use(requirePermissionForStudents('flashcards.access'));
router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
@@ -45,8 +48,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
router.get ('/decks/:id/shares', fc.listShares);
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
+7 -5
View File
@@ -1,14 +1,16 @@
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/gamesController');
const hangman = requireFeature('hangman');
const crossword = requireFeature('crossword');
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
const playable = requirePermissionForStudents('games.play');
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord);
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete);
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate);
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete);
router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
module.exports = router;
+2 -2
View File
@@ -1,10 +1,10 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const c = require('../controllers/liveController');
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
router.post('/', ...teacher, c.create);
router.post('/', ...teacher, requirePermission('livequiz.host'), c.create);
router.get('/:id', ...teacher, c.getSession);
router.put('/:id/question', ...teacher, c.setQuestion);
router.get('/:id/results', ...teacher, c.results);
+3 -2
View File
@@ -1,7 +1,7 @@
'use strict';
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const c = require('../controllers/studentMaterialsController');
router.use(authMiddleware);
@@ -10,7 +10,8 @@ router.use(authMiddleware);
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
router.get('/', c.list);
router.post('/', c.create);
// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
router.post('/', requirePermissionForStudents('materials.save'), c.create);
// Collections (folders) — literal '/collections' prefix before '/:id'
router.post('/collections', c.createCollection);
+3 -1
View File
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
const { default_mode, default_count, default_test_id } = req.body;
const valid_modes = ['exam', 'practice', 'topic', 'random'];
// Старт сессии (POST /api/sessions) поддерживает только exam/practice — раньше тут
// допускались topic/random, но клик по такому предмету на дашборде падал с 400.
const valid_modes = ['exam', 'practice'];
if (default_mode && !valid_modes.includes(default_mode))
return res.status(400).json({ error: 'Invalid mode' });
+3 -3
View File
@@ -1,7 +1,7 @@
const router = require('express').Router();
const multer = require('multer');
const path = require('path');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/submissionsController');
const { fixUtf8Name } = require('../utils/fixUtf8');
@@ -47,7 +47,7 @@ const upload = multer({
/* ── routes ─────────────────────────────────────────────────────────── */
router.use(authMiddleware);
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
@@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
router.get('/:id/download', ctrl.downloadSubmission);
router.delete('/:id', ctrl.deleteSubmission);
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/wishController');
router.use(authMiddleware);
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
router.post('/', ctrl.create); // любой авторизованный
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
router.patch('/:id', requireRole('admin'), ctrl.update);
router.delete('/:id', ctrl.remove);
module.exports = router;
+2
View File
@@ -198,6 +198,8 @@ app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/wishes', require('./routes/wishes'));
app.use('/api/client-errors', require('./routes/clientErrors'));
app.use('/api/prep', require('./routes/prep'));
app.use('/api/dashboard', require('./routes/dashboard'));
+141
View File
@@ -0,0 +1,141 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
systemReset.js — общая логика «чистого запуска» (используют и CLI
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
─────────────────────────────────────────────────────────────────────────── */
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
const REASSIGN = {
courses: 'created_by', tests: 'created_by',
flashcard_decks: 'user_id', custom_sims: 'owner_id',
course_templates: 'created_by', lesson_templates: 'created_by',
assignment_templates: 'created_by', lab_sim_links: 'created_by',
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
};
/* Активность/организация — полностью очищается. */
const WIPE = new Set([
'test_sessions', 'session_questions', 'user_answers',
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
'assignments', 'assignment_sessions', 'assignment_completion',
'submissions', 'submission_log',
'classes', 'class_members', 'class_courses',
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
'classroom_notes', 'classroom_pages', 'classroom_strokes',
'live_sessions', 'live_answers',
'content_access',
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
'notifications', 'parent_notifications', 'parent_links',
'student_materials', 'material_collections',
'game_progress',
'lesson_progress', 'lesson_comments', 'lesson_notes',
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
'flashcard_reviews', 'flashcard_deck_access',
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
'imggen_usage',
'folder_access', 'file_access',
'avatar_requests',
'geometry_submissions', 'geometry_tasks',
'security_events', 'error_log', 'admin_audit_log',
'student_prep',
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
]);
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
const KEEP = new Set([
'subjects', 'questions', 'options', 'topics',
'textbooks', 'textbook_chunks',
'lessons', 'lesson_blocks', 'course_sections',
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
'test_questions', 'flashcard_cards', 'lab_sims',
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
'shop_items', 'achievements',
'roles', 'role_permissions', 'app_settings', '_migrations',
]);
const ADMIN_RESET_SQL =
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
pet_petting_streak = 0 WHERE id = ?`;
function allTables(db) {
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
.all().map(r => r.name);
}
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
function pickKeptAdmin(db) {
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
}
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
function classify(db) {
const tables = allTables(db);
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
const totalUsers = rowCount(db, 'users');
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
}
/**
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
* которого сохраняем (ему переназначается контент). Возвращает сводку.
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
*/
function runReset(db, keptAdminId) {
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
db.exec('BEGIN');
try {
for (const [t, col] of Object.entries(REASSIGN)) {
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
}
for (const t of WIPE) {
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
}
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
db.exec('COMMIT');
var deletedUsers = del.changes;
} catch (e) {
db.exec('ROLLBACK');
db.exec('PRAGMA foreign_keys = ON');
throw e;
}
db.exec('PRAGMA foreign_keys = ON');
let fkBad = 0;
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
try { db.exec('VACUUM'); } catch {}
return {
ok: true,
keptAdminId,
deletedUsers,
remainingUsers: rowCount(db, 'users'),
fkDangling: fkBad,
kept: {
textbooks: rowCount(db, 'textbooks'),
questions: rowCount(db, 'questions'),
tests: rowCount(db, 'tests'),
courses: rowCount(db, 'courses'),
exam_tasks: rowCount(db, 'exam_tasks'),
},
};
}
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/**
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, db, cleanup } = require('./setup');
app.use('/api/client-errors', require('../src/routes/clientErrors'));
after(() => cleanup());
describe('/api/client-errors', () => {
let student;
before(async () => { student = await getToken('student'); });
it('требует авторизацию (401)', async () => {
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
assert.equal(res.status, 401);
});
it('пустое сообщение → 400', async () => {
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
assert.equal(res.status, 400);
});
it('пишет ошибку в error_log с level=client', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'error', message: 'TypeError: x is null',
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
url: '/lab?sim=demo#x',
}, student.token);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
).get(student.userId);
assert.ok(row, 'строка должна появиться');
assert.equal(row.message, 'TypeError: x is null');
assert.equal(row.route, '/lab?sim=demo#x');
assert.equal(row.method, 'error');
assert.match(row.stack, /app\.js:10:5/);
});
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
).get();
assert.equal(row.method, 'rejection');
assert.match(row.stack, /x\.js:3:1/);
});
it('длинные поля обрезаются (не падает)', async () => {
const res = await inject('POST', '/api/client-errors', {
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
assert.ok(row.message.length <= 1000);
assert.ok(row.stack.length <= 4000);
assert.ok(row.route.length <= 400);
});
});
+119
View File
@@ -0,0 +1,119 @@
'use strict';
/**
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
* (автор «новое» / админ; чужое нельзя).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
app.use('/api/wishes', require('../src/routes/wishes'));
after(() => cleanup());
describe('/api/wishes', () => {
let s1, s2, admin;
before(async () => {
s1 = await getToken('student');
s2 = await getToken('student');
admin = await getToken('admin');
});
it('POST /wishes requires auth (401)', async () => {
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
assert.equal(res.status, 401);
});
it('создание: пустой заголовок → 400', async () => {
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
assert.equal(res.status, 400);
});
let wishId;
it('создание пожелания учеником → 201, статус new', async () => {
const res = await inject('POST', '/api/wishes',
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
assert.equal(res.status, 201, JSON.stringify(res.body));
assert.equal(res.body.status, 'new');
assert.equal(res.body.category, 'ui');
assert.equal(res.body.user_id, s1.userId);
wishId = res.body.id;
});
it('неизвестная категория → other', async () => {
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
assert.equal(res.body.category, 'other');
});
it('приватность: автор видит свои', async () => {
const res = await inject('GET', '/api/wishes', null, s1.token);
assert.equal(res.status, 200);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.equal(res.body.isAdmin, false);
});
it('приватность: другой ученик НЕ видит чужое', async () => {
const res = await inject('GET', '/api/wishes', null, s2.token);
assert.ok(!res.body.wishes.some(w => w.id === wishId));
});
it('админ видит все + counts', async () => {
const res = await inject('GET', '/api/wishes', null, admin.token);
assert.equal(res.status, 200);
assert.equal(res.body.isAdmin, true);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
// у админа в списке есть имя автора
const w = res.body.wishes.find(x => x.id === wishId);
assert.ok(w.author_name);
});
it('триаж учеником запрещён (403)', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
assert.equal(res.status, 403);
});
it('админ меняет статус + ответ → 200', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`,
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
assert.equal(res.status, 200, JSON.stringify(res.body));
assert.equal(res.body.status, 'planned');
assert.equal(res.body.admin_note, 'Запланировано на лето');
});
it('неверный статус → 400', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
assert.equal(res.status, 400);
});
it('фильтр по статусу у админа', async () => {
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
assert.ok(res.body.wishes.some(w => w.id === wishId));
});
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
assert.equal(res.status, 403);
});
it('чужой ученик не может удалить → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
assert.equal(res.status, 403);
});
it('автор удаляет своё «новое» → 200', async () => {
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
assert.equal(res.status, 200);
});
it('админ удаляет любое → 200', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
assert.equal(res.status, 200);
const gone = await inject('GET', '/api/wishes', null, admin.token);
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
});
});
+11 -4
View File
@@ -486,6 +486,13 @@
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
.tst-search:focus { border-color: var(--violet); }
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
.tst-pick-sel:focus { border-color: var(--violet); }
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* formula bar */
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */
@@ -1071,7 +1078,7 @@
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
</button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
<i data-lucide="layout-grid" style="width:15px;height:15px"></i> Модули
</button>
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
@@ -1568,10 +1575,10 @@
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
</div>
<!-- ── Игры ── -->
<!-- ── Модули ── -->
<div class="tab-pane" id="tab-games">
<div class="section-title">Управление играми</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="section-title">Управление модулями</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="perm-grid" id="games-features-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div>
+127 -1
View File
@@ -109,6 +109,29 @@
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
/* ── Долги (что висит у учеников) ── */
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.debt-name { font-size: 0.9rem; font-weight: 700; }
.debt-email { font-size: 0.74rem; color: var(--text-3); }
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
/* ── Student search ── */
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
.student-search-wrap .form-input { width: 100%; }
@@ -628,6 +651,7 @@
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
@@ -667,6 +691,11 @@
<div class="assign-list" id="d-assignments"></div>
</div>
<!-- Debts (что висит у учеников) -->
<div class="tab-pane" id="dtab-debts">
<div id="debts-content"><div class="spinner"></div></div>
</div>
<!-- Journal -->
<div class="tab-pane" id="dtab-journal">
<div id="journal-content"><div class="spinner"></div></div>
@@ -794,7 +823,7 @@
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Загрузка работы</button>
</div>
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
Вопросы подбираются случайно из базы по выбранному предмету
@@ -1199,6 +1228,102 @@
}).join('');
}
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
const DEBT_STATUS = {
overdue: { label: 'просрочено', cls: 'dc-overdue' },
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
revision: { label: 'на доработке', cls: 'dc-revision' },
not_started: { label: 'не начато', cls: 'dc-not_started' },
};
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
let _debtData = null;
async function loadDebts() {
if (!currentClass) return;
const el = document.getElementById('debts-content');
el.innerHTML = '<div class="spinner"></div>';
try {
_debtData = await LS.classOutstanding(currentClass.id);
renderDebts();
} catch (e) {
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function debtChips(c) {
return ['overdue', 'in_progress', 'revision', 'not_started']
.filter(k => c[k] > 0)
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
.join('');
}
function debtItemHtml(s, p) {
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
return `<div class="debt-item">
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
<div style="flex:1;min-width:0">
<div class="debt-item-title">${esc(p.title)}</div>
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
</div>
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
</div>`;
}
function renderDebts() {
const el = document.getElementById('debts-content');
if (!_debtData) { el.innerHTML = ''; return; }
const { summary, students } = _debtData;
const withDebt = students.filter(s => s.counts.total > 0);
const badge = document.getElementById('debts-tab-badge');
if (badge) {
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
else badge.style.display = 'none';
}
if (!withDebt.length) {
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
if (window.lucide) lucide.createIcons();
return;
}
let html = `<div class="debt-summary">
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
</div>`;
html += withDebt.map(s => `
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
<div class="debt-head">
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
<div class="debt-chips">${debtChips(s.counts)}</div>
</div>
${s.pending.map(p => debtItemHtml(s, p)).join('')}
</div>`).join('');
const clear = summary.students_total - withDebt.length;
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
el.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function deleteDebtAssignment(id, scope, studentId) {
let title = 'задание', studentName = '';
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
if (stu) {
studentName = stu.name;
const it = stu.pending.find(p => p.assignment_id === id);
if (it) title = it.title;
}
const msg = scope === 'direct'
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAssignment(id);
LS.toast('Задание удалено', 'info');
await loadDebts();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ Detail tabs ══ */
function switchDetailTab(btn) {
const name = btn.dataset.tab;
@@ -1208,6 +1333,7 @@
document.getElementById('dtab-' + name).classList.add('active');
if (name === 'announce') loadAnnouncements();
if (name === 'dash') loadClassDashboard();
if (name === 'debts') loadDebts();
if (name === 'journal') loadJournal();
if (name === 'settings') loadSettings();
if (name === 'works') loadClassWorks();
+1 -1
View File
@@ -365,7 +365,7 @@
LS.sidebar?.init();
lucide.createIcons();
const feats = await LS.loadFeatures();
if (feats.collection === false) { window.location.replace('/403'); return; }
if (feats.collection === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
await loadCollection();
})();
+27 -16
View File
@@ -1039,7 +1039,7 @@ body {
body.no-class #lb-section { display: none !important; }
/* Gamification kill-switch.
When admin turns off the feature, body.no-gamification is set by
When admin turns off the feature, .no-gamification is set by
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
achievement / frame element must vanish across the whole app,
not just the dashboard. The rules below cover:
@@ -1050,21 +1050,32 @@ body.no-class #lb-section { display: none !important; }
a catch-all [data-gamified] hook that wraps any future block
authors of new pages should wrap XP UI in a <div data-gamified>
instead of inventing new classes. */
body.no-gamification .gam-bar,
body.no-gamification .lb-widget,
body.no-gamification .achievements-section,
body.no-gamification #tab-btn-achievements,
body.no-gamification #tab-btn-shop,
body.no-gamification #tab-achievements,
body.no-gamification #tab-shop,
body.no-gamification #frames-section,
body.no-gamification .hero-xp-badge,
body.no-gamification .po-xp,
body.no-gamification .xp-card,
body.no-gamification .xp-bar,
body.no-gamification .xp-pill,
body.no-gamification .xp-badge,
body.no-gamification [data-gamified] { display: none !important; }
.no-gamification .gam-bar,
.no-gamification .lb-widget,
.no-gamification .achievements-section,
.no-gamification #tab-btn-achievements,
.no-gamification #tab-btn-shop,
.no-gamification #tab-achievements,
.no-gamification #tab-shop,
.no-gamification #frames-section,
.no-gamification .hero-xp-badge,
.no-gamification .po-xp,
.no-gamification .xp-card,
.no-gamification .xp-bar,
.no-gamification .xp-pill,
.no-gamification .xp-badge,
/* challenges / еженедельные испытания (dashboard) */
.no-gamification .ch-widget,
.no-gamification #ch-section,
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
.no-gamification .streak-cal,
.no-gamification #sr-streak,
.no-gamification .hc-pet .chip-streak,
.no-gamification .hc-pet .chip-goal,
/* монеты (профиль) и xp-прогресс */
.no-gamification #p-coins-row,
.no-gamification .gam-progress,
.no-gamification [data-gamified] { display: none !important; }
/*
RESPONSIVE SMALL PHONES ( 480px)
+148 -24
View File
@@ -81,7 +81,33 @@
}
.ab-btn:hover { background: rgba(255,255,255,0.25); }
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
/* ── Live online-lesson banner ── */
.live-lesson {
display: flex; align-items: center; gap: 14px; text-decoration: none;
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
}
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
@keyframes llPulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
@media (max-width: 480px) {
.live-lesson { padding: 12px 14px; gap: 10px; }
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
}
.hero-card {
position: relative; border-radius: 18px; padding: 18px 20px;
display: flex; flex-direction: column; min-height: 196px;
@@ -1532,6 +1558,13 @@
<div class="container">
<!-- Live online-lesson status (student/teacher) -->
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
<span class="ll-dot"></span>
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
<span class="ll-cta" id="ll-cta">Присоединиться</span>
</a>
<!-- Gamification Bar (students only) -->
<div class="gam-bar" id="gam-bar" style="display:none">
<div class="gam-level">
@@ -1750,6 +1783,11 @@
<div class="widget" id="w-tests">
<div class="w-head"><div class="w-title">Тесты</div></div>
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
<div class="subj-mini-grid" id="available-tests-list"></div>
</div>
</div>
<!-- Col 3: Progress -->
@@ -1884,6 +1922,7 @@
<!-- Join modal -->
<!-- Quick-start test modal -->
<script src="/js/api.js"></script>
<script src="/js/assignment-utils.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
@@ -2222,10 +2261,14 @@
async function loadSubjects() {
const list = document.getElementById('subjects-list');
try {
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' };
const subjects = await LS.getSubjects();
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
const subjects = (await LS.getSubjects())
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
list.innerHTML = subjects.map((s, si) => {
const mode = s.default_mode || 'exam';
let mode = s.default_mode || 'exam';
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
const count = s.default_count || 25;
const testId = s.default_test_id || null;
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
@@ -2253,6 +2296,32 @@
window.location.href = url;
}
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
async function loadAvailableTests() {
const wrap = document.getElementById('avail-tests-wrap');
const list = document.getElementById('available-tests-list');
if (!wrap || !list) return;
try {
const tests = await LS.getTests();
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
list.innerHTML = tests.map((t, i) => {
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
const iconName = ICONS[t.subject_slug] || 'book-open';
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(t.title)}</div>
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
wrap.style.display = '';
reIcons();
} catch { wrap.style.display = 'none'; }
}
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
async function loadAssignments() {
try {
@@ -2346,15 +2415,8 @@
body.classList.toggle('collapsed');
}
/* ── Urgency sort score (lower = shown first) ── */
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4; // in progress <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> top
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
if (dlMs < 0) return -3; // overdue
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
if (dlMs < Infinity) return dlMs; // sorted by deadline
return 1e12; // no deadline <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> last
}
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
/* ── Is assignment urgent for teacher (within 48h) ── */
function isTeacherUrgent(a) {
@@ -2422,7 +2484,7 @@
}
/* ── Upload-only homework (no test, no file) ── */
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) {
if (AssignmentUtils.type(a) === 'upload') {
const over = a.deadline && new Date(a.deadline) < new Date();
const sub = _mySubmissions.get(a.id);
const metaParts = [classStr, dl ? `до ${dl}` : null,
@@ -2659,18 +2721,22 @@
reIcons(); return;
}
// Classify
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
function classify(a) {
const maxAtt = a.max_attempts || 0;
const usedAtt = a.attempts_used ?? 0;
if (a.textbook_id) {
if (a.completed_at || a.textbook_all_read) return 'done';
const t = AssignmentUtils.type(a);
if (t === 'textbook') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
if (t === 'test') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
return 'active';
}
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
@@ -3670,12 +3736,36 @@
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
}
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
function syncProgressCol() {
const col = document.getElementById('w-progress-col');
if (!col) return;
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
const e = document.getElementById(id);
return e && getComputedStyle(e).display !== 'none';
});
col.style.display = any ? '' : 'none';
}
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
function syncHeroRow() {
const row = document.getElementById('hero-row');
if (!row) return;
const vis = [...row.querySelectorAll('.hero-card')]
.filter(c => getComputedStyle(c).display !== 'none');
row.style.display = vis.length ? '' : 'none';
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
}
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
function loadLastResultsWidget(rows) {
const w = document.getElementById('w-last-results');
if (!w) return;
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
if (!completed.length) { w.style.display = 'none'; return; }
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('last-results-list').innerHTML = completed.map(h => {
const pct = Math.round(h.score / h.total * 100);
@@ -3689,6 +3779,7 @@
</div>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
@@ -3702,7 +3793,7 @@
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
});
const entries = Object.entries(bySubj);
if (!entries.length) { w.style.display = 'none'; return; }
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
@@ -3713,6 +3804,7 @@
<span class="sp-pct" style="color:${color}">${avg}%</span>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
@@ -4285,6 +4377,7 @@
loadLabOfDay();
loadPetHero();
loadFlashcardWidget();
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
}
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
@@ -4298,6 +4391,7 @@
renderFlashcardWidget(r);
w.style.display = '';
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
}
function renderFlashcardWidget(r) {
@@ -4473,6 +4567,7 @@
} else {
// Student: full layout
loadSubjects();
loadAvailableTests();
loadAssignments();
loadStats();
loadGamification();
@@ -4481,13 +4576,42 @@
loadDashboardStats();
applyDashboardPrefs();
}
loadLiveLesson();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
LS.notif.init();
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
async function loadLiveLesson() {
const el = document.getElementById('live-lesson-banner');
if (!el) return;
let data;
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
const s = data && data.session;
if (!s) { el.style.display = 'none'; return; }
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
let sub;
if (isTeacher) {
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
sub = online ? (online + ' онлайн') : 'ожидание учеников';
} else {
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
}
document.getElementById('ll-sub').textContent = sub;
document.getElementById('ll-cta').textContent = isTeacher
? 'Вернуться к доске'
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
el.style.display = '';
}
// Real-time SSE for page-specific events (notif handled by notifications.js)
LS.connectSSE(ev => {
if (ev.type === 'assignment') {
LS.toast(ev.message, 'info');
isTeacher ? loadAdminAssignments() : loadAssignments();
} else if (ev.type === 'classroom_live') {
loadLiveLesson();
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();
+228 -22
View File
@@ -125,6 +125,41 @@
/* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
/* ── section titles (multi-block student view) ── */
.hw-sec-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
}
.hw-sec-count {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: var(--violet); background: rgba(155,93,229,0.1);
padding: 2px 9px; border-radius: 999px;
}
#hw-active-wrap { margin-bottom: 28px; }
/* ── active assignment cards ── */
.hw-acard {
background: #fff; border-radius: 16px; padding: 16px 18px;
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
display: flex; align-items: center; gap: 14px; transition: all .15s;
}
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.hw-acard.over { border-left-color: #EF476F; }
.hw-acard.urgent { border-left-color: #F59E0B; }
.hw-acard-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-acard-body { flex: 1; min-width: 0; }
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@media (max-width: 768px) {
.container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; }
@@ -133,6 +168,8 @@
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; }
.hw-acard { flex-wrap: wrap; }
.hw-acard-right { width: 100%; justify-content: flex-end; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 80px; }
@@ -152,8 +189,15 @@
<div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: active assignments (что нужно сделать) -->
<div id="hw-active-wrap" style="display:none">
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
<div class="hw-list" id="hw-active-list"></div>
</div>
<!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none">
<div class="hw-sec-title">Сдать работу</div>
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div>
@@ -195,6 +239,7 @@
</div>
<!-- Student: status filters -->
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
<div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
@@ -213,6 +258,7 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/assignment-utils.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
@@ -247,6 +293,14 @@
resubmitted: 'Повторно', accepted: 'Принято'
};
/* subject label/colour/icon maps (как на дашборде) */
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
let _assignments = []; // актуальные задания (LS.myAssignments)
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
/* ── filter ── */
function filterStatus(st, btn) {
_statusFilter = st;
@@ -257,33 +311,31 @@
/* ── STUDENT VIEW ── */
async function initStudent() {
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
document.getElementById('hw-top-student').style.display = '';
document.getElementById('hw-mysubs-title').style.display = '';
// Find student's class
// Find student's class (нужен для загрузки работ без привязки к заданию)
try {
const classes = await LS.myClasses();
if (classes.length) {
_studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = '';
// Load assignments for selector
try {
const feed = await LS.classFeed(classes[0].id);
const sel = document.getElementById('hw-assignment-sel');
(feed.assignments || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title;
sel.appendChild(opt);
});
} catch {}
}
} catch {}
// Load submissions
// Грузим актуальные задания (все классы) + сдачи параллельно
try {
_submissions = await LS.getMySubmissions();
const [assigns, subs] = await Promise.all([
LS.myAssignments().catch(() => []),
LS.getMySubmissions().catch(() => []),
]);
_assignments = Array.isArray(assigns) ? assigns : [];
_submissions = Array.isArray(subs) ? subs : [];
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
populateAssignmentSelect(_assignments);
renderActiveAssignments();
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
@@ -312,14 +364,22 @@
}
async function submitHomework() {
if (!_selectedFile || !_studentClassId) return;
if (!_selectedFile) return;
const sel = document.getElementById('hw-assignment-sel');
const assignId = sel.value;
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
// иначе — первый класс ученика.
let classId = _studentClassId;
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
classId = sel.selectedOptions[0].dataset.class;
}
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
const btn = document.getElementById('hw-submit-btn');
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', _selectedFile);
fd.append('class_id', _studentClassId);
const assignId = document.getElementById('hw-assignment-sel').value;
fd.append('class_id', classId);
if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg);
@@ -336,12 +396,20 @@
// Reload
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; }
}
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
function syncStudentLists() {
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
renderActiveAssignments();
renderSubmissions();
}
async function resubmitHomework(subId) {
const input = document.createElement('input');
input.type = 'file';
@@ -355,7 +423,7 @@
await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions();
renderSubmissions();
syncStudentLists();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
};
input.click();
@@ -366,7 +434,7 @@
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
syncStudentLists();
LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
@@ -381,6 +449,144 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
function asgnType(a) { return AssignmentUtils.type(a); }
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
function deadlineChip(a) {
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
const dlMs = new Date(a.deadline) - Date.now();
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
const days = Math.ceil(dlMs / 86400000);
const txt = days === 1 ? '1 день' : `${days} дн.`;
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
}
function actionFor(a) {
const t = asgnType(a);
if (t === 'textbook') {
let hash = '';
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
}
if (t === 'file') {
const sub = _subByAsgn.get(a.id);
const submit = sub && sub.status !== 'revision'
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
}
if (t === 'upload') {
const sub = _subByAsgn.get(a.id);
if (sub && sub.status !== 'revision') {
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
}
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
}
// test
const inProgress = a.session_status === 'in_progress';
const isDone = a.session_status === 'completed';
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
}
function activeCardHtml(a) {
const t = asgnType(a);
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
const icon = t === 'textbook' ? 'book-open-text'
: t === 'file' ? 'paperclip'
: t === 'upload' ? 'upload'
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const over = dlMs < 0;
const urgent = !over && dlMs < 24 * 3600 * 1000;
const cls = over ? ' over' : urgent ? ' urgent' : '';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
return `<div class="hw-acard${cls}" style="--ac:${color}">
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
<div class="hw-acard-body">
<div class="hw-acard-title">${esc(a.title)}</div>
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
</div>
<div class="hw-acard-right">${actionFor(a)}</div>
</div>`;
}
function renderActiveAssignments() {
const wrap = document.getElementById('hw-active-wrap');
const list = document.getElementById('hw-active-list');
if (!wrap || !list) return;
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
// обычные тесты/экзамены сюда не попадают.
const active = _assignments
.filter(a => a.is_homework && !asgnDone(a))
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
if (!active.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
document.getElementById('hw-active-count').textContent = active.length;
list.innerHTML = active.map(activeCardHtml).join('');
lucide.createIcons();
}
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
function populateAssignmentSelect(list) {
const sel = document.getElementById('hw-assignment-sel');
if (!sel) return;
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
opt.dataset.asgn = '1';
if (a.class_id) opt.dataset.class = a.class_id;
sel.appendChild(opt);
});
}
// Начать/продолжить тест-задание (как на дашборде).
async function startAsgn(e, id, mode) {
const btn = e.currentTarget;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = orig; return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
} catch (err) {
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
btn.disabled = false; btn.textContent = orig;
}
}
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
function sdatNow(assignId) {
const wrap = document.getElementById('hw-upload-wrap');
if (!wrap || wrap.style.display === 'none') {
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
}
const sel = document.getElementById('hw-assignment-sel');
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
const area = document.getElementById('hw-upload-area');
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
}
/* ── TEACHER VIEW ── */
async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
+8 -2
View File
@@ -284,9 +284,15 @@
el.innerHTML = rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
const isClient = r.level === 'client';
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
const badge = isClient
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
: '';
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
${badge}
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
</div>
+5 -5
View File
@@ -311,7 +311,7 @@
}
async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const classes = _targets.classes || [];
try {
await Promise.all(classes.map(c =>
@@ -436,7 +436,7 @@
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
if (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) return;
try {
const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
@@ -449,7 +449,7 @@
}
async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
if (!allow && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try {
await Promise.all(all.map(([type, ref]) =>
@@ -543,7 +543,7 @@
const classes = _matrix.classes || [];
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
const open = !allOpen;
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try {
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
@@ -557,7 +557,7 @@
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
const open = !allOpen;
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
if (!open && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
+5
View File
@@ -5,6 +5,7 @@
let inited = false;
const GAME_FEATURES = [
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
@@ -13,12 +14,16 @@
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'sitemap', label: 'Путеводитель', desc: 'Пункт «Путеводитель» в меню — обзорная карта разделов системы', icon: 'map' },
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
];
const FS_FEATURES = [
+128
View File
@@ -452,6 +452,24 @@
<i data-lucide="file-text"></i> Audit log
</button>
</div>
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
<div class="ov-card danger" style="padding-bottom:16px">
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
Сброс системы «чистый запуск»
</div>
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются авторский контент
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
Действие необратимо.
</div>
<button class="ov-quick-btn" id="ov-reset-system-btn"
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
<i data-lucide="trash-2"></i> Сбросить систему
</button>
</div>
`;
/* ── wire quick-links via event delegation ───────────────── */
@@ -459,9 +477,119 @@
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
});
const resetBtn = el.querySelector('#ov-reset-system-btn');
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
if (window.lucide) lucide.createIcons({ nodes: [el] });
}
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
async function openResetModal() {
const e = LS.esc;
const m = LS.modal({
title: 'Сброс системы — чистый запуск',
size: 'md',
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
actions: [{ label: 'Отмена' }],
});
let plan;
try {
plan = await LS.api('/api/admin/reset-system/plan');
} catch (err) {
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
return;
}
const kept = plan.keptAdmin || {};
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
const wipeRows = plan.wipeRows || 0;
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
return a + (typeof r.rows === 'number' ? r.rows : 0);
}, 0);
const unknownNote = (plan.unknown && plan.unknown.length)
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
: '';
m.setBody(
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
'</div>' +
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
' <span style="color:#56687A">(вы)</span></div>' +
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
'</ul>' +
unknownNote +
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
'</div>'
);
const inp = m.body.querySelector('#ov-reset-confirm-inp');
function syncBtn() {
const ok = inp && inp.value.trim() === 'СБРОС';
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = !ok;
}
function setReadyActions() {
m.setActions([
{ label: 'Отмена' },
{
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
onClick: doReset,
},
]);
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = true;
}
async function doReset() {
const btn = document.getElementById('ov-reset-go');
if (!inp || inp.value.trim() !== 'СБРОС') return;
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
m.setError('');
let res;
try {
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
} catch (err) {
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
return;
}
m.setBody(
'<div style="text-align:center;padding:14px 0">' +
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
(res.remainingUsers || 1) + '</strong>.<br>' +
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
'</div>' +
'</div>'
);
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
}
setReadyActions();
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
}
async function load() {
const el = document.getElementById('overview-content');
if (!el) return;
+2 -1
View File
@@ -4,7 +4,8 @@
'use strict';
let inited = false;
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
// Старт сессии поддерживает только exam/practice (topic/random убраны — давали 400 на дашборде).
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
+112 -20
View File
@@ -40,10 +40,12 @@
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
@@ -77,16 +79,15 @@
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const t = await LS.getTest(id);
const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
// Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
const prev = _tstPickerCache[id] || {};
_tstPickerCache[id] = {
subject_slug: t.subject_slug, inIds,
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
rows: [], total: 0, page: 1, loading: false,
};
inner.innerHTML = `
<div class="tst-cols">
@@ -96,17 +97,89 @@
</div>
<div>
<div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
<div class="tst-pick-filters">
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любая сложность</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любой тип</option>
<option value="single">Один</option>
<option value="multi">Несколько</option>
<option value="true_false">Верно/Нет</option>
<option value="short_answer">Краткий</option>
<option value="matching">Сопоставление</option>
</select>
</div>
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
</div>
</div>`;
// restore search/filters into controls
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
AdminCtx.renderMath(inner);
if (window.lucide) lucide.createIcons();
await pickerLoad(id, true);
} catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
reset=true новый поиск/фильтр (страница 1, заменяем); иначе «показать ещё». */
async function pickerLoad(id, reset) {
const cache = _tstPickerCache[id];
if (!cache || cache.loading) return;
cache.loading = true;
if (reset) { cache.page = 1; cache.rows = []; }
const listEl = document.getElementById('tstpicker-' + id);
const footEl = document.getElementById('tstfoot-' + id);
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
try {
const p = new URLSearchParams();
p.set('subject', cache.subject_slug || '');
p.set('sort', 'date_desc');
p.set('page', cache.page);
p.set('limit', 100);
if (cache.q) p.set('q', cache.q);
if (cache.difficulty) p.set('difficulty', cache.difficulty);
if (cache.type) p.set('type', cache.type);
const data = await LS.get('/api/questions?' + p.toString());
const rows = Array.isArray(data) ? data : (data.rows || []);
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
cache.rows = reset ? rows : cache.rows.concat(rows);
cache.page += 1;
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
if (footEl) footEl.innerHTML = pickerFootHtml(id);
if (window.lucide) lucide.createIcons();
} catch (e) {
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
} finally { cache.loading = false; }
}
function pickerFootHtml(id) {
const c = _tstPickerCache[id];
if (!c || !c.total) return '';
const more = c.rows.length < c.total
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
}
function pickerMore(id) { pickerLoad(id, false); }
function pickerFilterChange(id) {
const c = _tstPickerCache[id];
if (!c) return;
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
c.type = document.getElementById('tstft-' + id)?.value || '';
pickerLoad(id, true);
}
function renderTstQList(questions, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
@@ -127,7 +200,11 @@
function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
if (!questions.length) {
const c = _tstPickerCache[tid] || {};
const searching = c.q || c.difficulty || c.type;
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</div>`;
}
return questions.map(q => {
const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
@@ -145,15 +222,13 @@
}).join('');
}
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
const _pickDebounce = {};
function filterTstPicker(tid) {
const cache = _tstPickerCache[tid];
if (!cache) return;
const filtered = search
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
: cache.subjectQs;
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
clearTimeout(_pickDebounce[tid]);
_pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
}
async function tstAddQ(tid, qid) {
@@ -261,11 +336,27 @@
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
async function toggleTstAvail(id) {
const t = allTests.find(x => x.id === id);
if (!t) return;
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
const next = t.available_to_students ? 0 : 1;
try {
await LS.updateTest(id, { available_to_students: next });
t.available_to_students = next;
renderTests();
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers
window.loadTests = load;
window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker;
window.pickerMore = pickerMore;
window.pickerFilterChange = pickerFilterChange;
window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers;
@@ -274,6 +365,7 @@
window.closeTstModal = closeTstModal;
window.saveTst = saveTst;
window.deleteTst = deleteTst;
window.toggleTstAvail = toggleTstAvail;
window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = {
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* assignment-utils.js единый источник правды для классификации заданий и статуса «сдано».
*
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
* assignmentController.js) и начала расходиться. Теперь один модуль, который грузится
* и в браузере (window.AssignmentUtils), и в Node (module.exports) как svg-sanitize.js.
*
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') window.AssignmentUtils = api;
})(this, function () {
/* Тип задания: textbook | file | upload | test.
Порядок проверки: учебник файл загрузка работы тест. */
function type(a) {
if (a.textbook_id) return 'textbook';
if (a.file_id) return 'file';
if (a.is_homework && (a.count == null || a.count <= 1)
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
return 'test';
}
/* «Закрыто» (сдано/выполнено/прочитано) задание уходит из активных/долгов.
sub последняя сдача (объект с .status) для upload/file, иначе null/undefined.
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
status==='accepted' (пока не приняли у ученика «висит»).
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
(ученик свою часть сделал это уже не его долг). */
function isDone(a, sub, opts) {
opts = opts || {};
const t = type(a);
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
if (t === 'upload' || t === 'file') {
const st = sub && sub.status;
if (!st || st === 'revision') return false;
return opts.acceptedOnly ? st === 'accepted' : true;
}
// test
const maxAtt = a.max_attempts || 0;
const used = (a.attempts_used != null) ? a.attempts_used : 0;
if (maxAtt > 0 && used >= maxAtt) return true;
return a.session_status === 'completed' && a.mode !== 'repeat';
}
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4;
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
if (dlMs < 0) return -3;
if (dlMs < 24 * 3600 * 1000) return -2;
if (dlMs < Infinity) return dlMs;
return 1e12;
}
return { type, isDone, urgencyScore };
});
+4 -1
View File
@@ -197,7 +197,10 @@
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
title: s.user_name || '—',
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
act: 'Открыть', actHash: '/admin#sessions', solid: true,
// Глубокая ссылка на ДЕТАЛИ конкретной сессии (открывается при любом статусе):
// список /admin#sessions показывает только completed, поэтому зависшая (in_progress)
// там не находилась. На странице деталей её можно посмотреть и удалить.
act: 'Открыть', actHash: '/admin#sessions/' + s.id, solid: true,
});
});
const ab = d.abandonedSessions24h || 0;
+9 -3
View File
@@ -34,6 +34,12 @@
const pickerOver = document.getElementById('vp-overlay');
const pickerGrid = document.getElementById('vp-grid');
/* Человекочитаемая метка варианта (ЦТ-2015 и т.п.); фолбэк — «Вариант N». */
const labelOf = (n) => {
const v = variants.find(x => x.n === n);
return (v && v.label) || `Вариант ${n}`;
};
/* ── Picker overlay ─────────────────────────────────────────── */
function buildGrid() {
pickerGrid.innerHTML = variants.map(v => {
@@ -45,7 +51,7 @@
const active = v.n === currentN ? ' active' : '';
const title = `${v.label} · решено ${v.solved}/${v.total}` +
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.label || v.n}</button>`;
}).join('');
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
@@ -74,7 +80,7 @@
/* ── Variant rendering ──────────────────────────────────────── */
async function selectVariant(n) {
currentN = n;
pickerLabel.textContent = `Вариант ${n}`;
pickerLabel.textContent = labelOf(n);
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
if (!tasksCache.has(n)) {
@@ -94,7 +100,7 @@
}
function renderVariant(n, tasks) {
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
main.innerHTML = `<div class="vp-title">${labelOf(n)}<small>${tasks.length} заданий</small></div>`;
const variantMeta = variants.find(v => v.n === n);
const solvedTracked = new Set(); // tasks already solved this session
+416 -65
View File
@@ -53,6 +53,8 @@ class TrigCircleSim {
this.graphFn = 'sin';
this.snapToNotable = true;
this.animating = false;
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
this.showParity = false; // показать зеркальную точку −α (чётность/нечётность)
this._cx = 0; this._cy = 0; this._r = 0;
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
@@ -96,11 +98,14 @@ class TrigCircleSim {
this._drawBg(c);
this._drawCircle(c);
if (this.eq) this._drawEquation(c);
if (this.showParity) this._drawParity(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
if (window.LabFX) LabFX.particles.draw(c);
c.restore();
this._ovClearUnused();
this._fireUpdate();
}
@@ -116,6 +121,103 @@ class TrigCircleSim {
this._layout(); this.draw();
}
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
setEquation(fn, a, sols) {
this.eq = { fn, a, sols: sols || [] };
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
this.draw();
}
clearEquation() { this.eq = null; this.draw(); }
_drawEquation(c) {
const cx = this._cx, cy = this._cy, r = this._r;
const { fn, a, sols } = this.eq;
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
c.save();
/* направляющая линия значения */
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
c.beginPath();
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
c.stroke(); c.setLineDash([]);
/* точки-решения + подписи градусов */
c.font = 'bold 11px Manrope,sans-serif';
sols.forEach(ang => {
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
});
c.restore();
}
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
_drawParity(c) {
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
c.save();
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
c.restore();
}
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
_ov() {
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
return this._ovEl;
}
/* key стабильный id подписи; latex LaTeX (дробь/корень KaTeX, иначе текст);
x,y CSS-px над canvas; anchor: c|l|r|t|b; boxed тёмная плашка (для координат). */
_ovLabel(key, latex, x, y, color, anchor, boxed) {
const ov = this._ov(); if (!ov) return;
this._ovMap = this._ovMap || {};
this._ovUsed = this._ovUsed || {};
let rec = this._ovMap[key];
if (!rec) {
const el = document.createElement('div');
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
el.style.willChange = 'transform';
ov.appendChild(el);
rec = this._ovMap[key] = { el, last: null, boxed: null };
}
if (rec.last !== latex) {
// Любая LaTeX-команда (\pi, \tfrac, \sin…) → KaTeX; простой текст/число — быстро текстом.
const useK = /\\/.test(latex) && (typeof window !== 'undefined' && window.katex);
if (useK) rec.el.innerHTML = window.katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false });
else rec.el.textContent = latex;
rec.last = latex;
}
if (rec.boxed !== !!boxed) {
rec.el.style.cssText += boxed
? ';background:rgba(12,12,22,0.82);border:1px solid rgba(155,93,229,0.3);border-radius:8px;padding:3px 9px'
: ';background:none;border:none;padding:0';
rec.boxed = !!boxed;
}
rec.el.style.color = color || '#fff';
const a = anchor || 'c';
const tr = a === 'r' ? 'translate(-100%,-50%)' : a === 'l' ? 'translate(0,-50%)'
: a === 't' ? 'translate(-50%,0)' : a === 'b' ? 'translate(-50%,-100%)' : 'translate(-50%,-50%)';
rec.el.style.transform = `translate(${x}px,${y}px) ${tr}`;
rec.el.style.display = '';
this._ovUsed[key] = true;
}
_ovClearUnused() {
if (!this._ovMap) return;
for (const k in this._ovMap) if (!(this._ovUsed && this._ovUsed[k])) this._ovMap[k].el.style.display = 'none';
this._ovUsed = {};
}
goToAngle(rad) {
this._animTarget = this._norm(rad);
if (!this.animating) this._startAnim();
@@ -130,7 +232,16 @@ class TrigCircleSim {
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
const deg = a * 180 / Math.PI;
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
// Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
let ref;
if (a <= Math.PI / 2) ref = a;
else if (a <= Math.PI) ref = Math.PI - a;
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
else ref = 2 * Math.PI - a;
return {
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
refAngle: ref, refDeg: ref * 180 / Math.PI,
};
}
/* ═══ Layout ═══════════════════════════════════════════════════════ */
@@ -290,11 +401,10 @@ class TrigCircleSim {
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
c.strokeStyle = ag; c.lineWidth = 2.5;
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
/* label */
const mid = a / 2, lr = ar + 18;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
/* label (KaTeX overlay: π-доля для табличных, иначе текст) */
const mid = a / 2, lr = ar + 20;
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
}
/* ── radius ── */
@@ -388,9 +498,9 @@ class TrigCircleSim {
/* ── axis value badges ── */
if (this.showSin && Math.abs(sinA) > 0.04)
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
if (this.showCos && Math.abs(cosA) > 0.04)
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
/* ── main point ── */
const ps = this._hover || this._drag ? 10 : 8;
@@ -406,8 +516,12 @@ class TrigCircleSim {
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
/* ── coordinate tooltip ── */
this._tooltip(c, px, py, cosA, sinA);
/* coordinate tooltip (KaTeX overlay) выносим РАДИАЛЬНО НАРУЖУ за точку,
чтобы не перекрывать центральную дугу угла и её подпись */
const _odx = Math.cos(a), _ody = -Math.sin(a);
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
'#fff', cosA >= 0 ? 'l' : 'r', true);
/* ── quadrant roman numeral ── */
const qOff = r * 0.46;
@@ -538,7 +652,6 @@ class TrigCircleSim {
const fn = this.graphFn;
const col = _TC[fn] || _TC.sin;
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
@@ -575,29 +688,30 @@ class TrigCircleSim {
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
}
/* ±1 lines */
if (fn==='sin'||fn==='cos') {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
c.setLineDash([]);
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
c.textAlign='right'; c.textBaseline='middle';
c.fillText('1', gx-5, sy(1)); c.fillText('1', gx-5, sy(-1));
}
/* ── шкала значений по оси Y (значения на координатной плоскости) ── */
const yVals = (fn==='tan'||fn==='cot')
? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
: [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
yVals.forEach(([v, lx], i) => {
const yy = sy(v);
if (yy < gy + 6 || yy > gy + gh - 6) return;
if (v !== 0) {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.lineWidth = 1; c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(gx, yy); c.lineTo(gx+gw, yy); c.stroke(); c.setLineDash([]);
}
this._ovLabel('gy' + i, lx, gx - 6, yy, 'rgba(255,255,255,0.55)', 'r');
});
/* x ticks */
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
c.textAlign='center'; c.textBaseline='top';
for (const [v,l] of ticks) {
/* x ticks — линии на canvas, подписи KaTeX-оверлеем */
const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
[3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
ticks.forEach(([v, lx], i) => {
const xx = sx(v);
if (xx < gx+6 || xx > gx+gw-6) continue;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
c.setLineDash([3,3]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
c.setLineDash([]);
c.fillText(l, xx, gy+gh+6);
}
if (xx < gx+6 || xx > gx+gw-6) return;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.setLineDash([3,3]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); c.setLineDash([]);
this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
});
/* ── ghost curves (other functions, dimmed) ── */
c.save();
@@ -669,6 +783,21 @@ class TrigCircleSim {
}
c.stroke();
/* ── развёртка: ярче выделяем кривую на [0, α] — как угол «разворачивается» в график ── */
{
const aMax = Math.min(Math.max(this.angle, 0), xMax);
c.strokeStyle = col; c.lineWidth = 4.5; c.lineCap = 'round'; c.lineJoin = 'round';
c.shadowColor = col; c.shadowBlur = 6;
c.beginPath(); let onS = false;
for (let x = 0; x <= aMax + 1e-9; x += step) {
const yv = evFn(x);
if (!isFinite(yv) || Math.abs(yv) > yR * 2) { onS = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!onS) { c.moveTo(spx, spy); onS = true; } else c.lineTo(spx, spy);
}
c.stroke(); c.shadowBlur = 0;
}
/* ── current angle marker ── */
const curY = evFn(this.angle);
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
@@ -687,31 +816,21 @@ class TrigCircleSim {
c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.7)';
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
/* value badge */
const txt = this._fmt(curY);
c.font = 'bold 11px Manrope,sans-serif';
const tm = c.measureText(txt);
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
c.fillStyle='rgba(12,12,22,0.85)';
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(txt, bx2+7, by2);
/* value badge (KaTeX overlay) */
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
/* подпись угла на оси X (развёртка: где текущий угол на графике) */
this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
mx, gy + 5, _TC.violet, 't', true);
}
c.restore();
/* fn name badge */
c.font='bold 13px Manrope,sans-serif';
const tm2 = c.measureText(lbl);
const bw3 = tm2.width+18, bh3 = 26;
c.fillStyle='rgba(12,12,22,0.7)';
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(lbl, gx+17, gy+21);
/* fn name badge (KaTeX-оверлей) */
const _glblTex = fn === 'sin' ? 'y = \\sin x'
: fn === 'cos' ? 'y = \\cos x'
: fn === 'tan' ? 'y = \\operatorname{tg} x'
: 'y = \\operatorname{ctg} x';
this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
}
/* ═══ Snap particles ═══════════════════════════════════════════════ */
@@ -1029,6 +1148,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
if (window.LabFX) LabFX.sound.play('click');
}
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
goToAngle нормализует заодно демонстрирует котерминальность. */
function trigSetAngleDeg(inp) {
if (!trigSim || !inp) return;
const v = parseFloat(String(inp.value || '').replace(',', '.'));
if (!isFinite(v)) return;
trigSim.goToAngle(v * Math.PI / 180);
}
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
/* Показать/скрыть график функций (тема «функции» по умолчанию можно убрать,
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
function trigToggleGraph(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.toggleLayer('graph', on);
const fns = document.getElementById('trig-graph-fns');
if (fns) fns.style.display = on ? '' : 'none';
}
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
function _trigSolveAngles(fn, a) {
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
let raw;
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
const out = [];
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
return out.sort((p, q) => p - q);
}
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
function _radLatex(rad) {
const P = Math.PI;
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
for (const [v, l] of T) {
if (Math.abs(rad - v) < 1e-6) return l;
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
}
return null;
}
/* Общая формула решения (LaTeX) или {none:true}. */
function _trigEqFormulaLatex(fn, a) {
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
if (fn === 'sin') {
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
if (fn === 'cos') {
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
}
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
var trigEqFn = 'sin';
function trigSetEqFn(fn, btn) {
trigEqFn = fn;
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trigSolve() {
if (!trigSim) return;
const inp = document.getElementById('trig-eq-input');
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
const fEl = document.getElementById('trig-eq-formula');
const sEl = document.getElementById('trig-eq-sols');
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
const sols = _trigSolveAngles(trigEqFn, a);
trigSim.setEquation(trigEqFn, a, sols);
const K = window.katex;
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
const f = _trigEqFormulaLatex(trigEqFn, a);
if (fEl) {
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
}
if (sEl) sEl.textContent = sols.length
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
: '';
}
function trigClearEq() {
if (!trigSim) return;
trigSim.clearEquation();
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
}
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
function _trigBuildValueTable() {
const el = document.getElementById('trig-table');
if (!el || el.dataset.built) return;
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
const body = [0, 30, 45, 60, 90].map(deg => {
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
}).join('');
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
el.dataset.built = '1';
}
function trigToggleTable(rowEl) {
const on = rowEl.classList.toggle('active');
const el = document.getElementById('trig-table');
if (!el) return;
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
else el.style.display = 'none';
}
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
function trigToggleParity(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.showParity = on;
trigSim.draw();
const pEl = document.getElementById('trig-parity');
if (!pEl) return;
pEl.style.display = on ? '' : 'none';
if (on && !pEl.dataset.built) {
pEl.innerHTML =
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
pEl.dataset.built = '1';
}
}
function _trigUpdateUI(s) {
const _f = v => {
if (v === undefined) return '—';
@@ -1044,25 +1301,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
};
const degStr = s.deg.toFixed(1) + '°';
// Panel values (nice fractions)
document.getElementById('trig-v-sin').textContent = _f(s.sin);
document.getElementById('trig-v-cos').textContent = _f(s.cos);
document.getElementById('trig-v-tan').textContent = _f(s.tan);
document.getElementById('trig-v-cot').textContent = _f(s.cot);
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
const setMathVal = (id, v) => {
const el = document.getElementById(id); if (!el) return;
const lx = _latexVal(v);
if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
else el.textContent = lx;
};
setMathVal('trig-v-sin', s.sin);
setMathVal('trig-v-cos', s.cos);
setMathVal('trig-v-tan', s.tan);
setMathVal('trig-v-cot', s.cot);
// Angle badge
// Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
const al = _angleLatex(s.angle);
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
document.getElementById('trig-angle-badge').innerHTML =
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`;
`<div>${_tex(head)}</div>` +
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
// Stats bar (nice fractions)
// Опорный (острый) угол — guarded (панель может не иметь элемента)
const refEl = document.getElementById('trig-ref');
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
// Знаки функций в текущей четверти
const signsEl = document.getElementById('trig-signs');
if (signsEl) {
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '' : '0');
signsEl.innerHTML =
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
}
// Точные значения + формула приведения (только для табличных углов)
const fEl = document.getElementById('trig-formula');
if (fEl) {
const beta = Math.round(s.refDeg);
const degR = Math.round(s.deg);
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
if (!isTable) {
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
} else {
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
const K = window.katex;
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
let html = '';
if (reduce) {
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
: `360^\\circ - ${beta}^\\circ`;
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
}
const line = (nm, color, val) => {
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
};
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
}
}
// Подсветка строки таблицы значений (по опорному острому углу)
const tbl = document.getElementById('trig-table');
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
const beta = Math.round(s.refDeg);
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
});
}
// Stats bar — значения тоже KaTeX (дроби/корни)
document.getElementById('trigbar-angle').textContent = degStr;
document.getElementById('trigbar-sin').textContent = _f(s.sin);
document.getElementById('trigbar-cos').textContent = _f(s.cos);
document.getElementById('trigbar-tan').textContent = _f(s.tan);
document.getElementById('trigbar-cot').textContent = _f(s.cot);
setMathVal('trigbar-sin', s.sin);
setMathVal('trigbar-cos', s.cos);
setMathVal('trigbar-tan', s.tan);
setMathVal('trigbar-cot', s.cot);
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
}
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
function _latexVal(v) {
if (v === undefined) return '\\text{не опр.}';
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
if (a < 5e-4) return '0';
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
if (Math.abs(a - 1) < 1e-3) return sg + '1';
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
return v.toFixed(3);
}
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
function _tex(latex) {
const K = window.katex;
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
}
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
function _piLabelToLatex(l) {
if (l === '0') return '0';
const conv = s => s.replace('π', '\\pi');
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
return conv(l);
}
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
function _angleLatex(rad) {
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
return null;
}
/* ── KaTeX live preview ── */
/** Convert user ascii expression <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> LaTeX string for KaTeX preview */
+1 -1
View File
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
LS.notif.init();
lucide.createIcons();
const feats = await LS.loadFeatures();
if (feats.knowledge_map === false) { window.location.replace('/403'); return; }
if (feats.knowledge_map === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
+78 -4
View File
@@ -506,6 +506,16 @@
<!-- left panel -->
<div class="proj-panel" style="width:240px;gap:0">
<!-- Angle input -->
<div class="gp-section-title" style="margin-bottom:8px">Угол, °</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<input id="trig-angle-input" type="number" step="1" placeholder="напр. 150"
onkeydown="trigAngleKey(event,this)"
style="flex:1;min-width:0;padding:7px 10px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
<button class="preset-btn" style="flex-shrink:0;padding:7px 12px" title="Перейти к углу"
onclick="trigSetAngleDeg(document.getElementById('trig-angle-input'))"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<!-- Function toggles -->
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
@@ -535,9 +545,14 @@
</label>
</div>
<!-- Graph function selector -->
<div class="gp-section-title" style="margin-bottom:8px">График</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
<!-- Graph (functions) — optional, can be hidden to focus on the circle -->
<label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">График</span>
<span class="tri-layer-hint" style="color:var(--text-3)">функции</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-graph-fns" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
<button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
@@ -553,6 +568,55 @@
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot"></span>
</div>
<!-- Reference (acute) angle + signs by quadrant -->
<div class="gp-section-title" style="margin-bottom:8px">Опорный угол · знаки</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.78rem">
<span style="color:var(--text-3)">острый угол к оси</span>
<span id="trig-ref" style="font-weight:800;color:var(--violet)"></span>
</div>
<div id="trig-signs" style="text-align:center;font-size:0.72rem;color:var(--text-2)"></div>
</div>
<!-- Exact values + reduction formula (table angles) -->
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px"></div>
<!-- Equation solver: fn(x) = a -->
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
</div>
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
<!-- Values table (first quadrant), toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleTable(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Таблица значений</span>
<span class="tri-layer-hint" style="color:var(--text-3)">090°</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-table" style="display:none;margin-bottom:14px;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:6px 8px;overflow-x:auto"></div>
<!-- Parity (−α) + periods toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleParity(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Чётность (−α)</span>
<span class="tri-layer-hint" style="color:var(--text-3)">симметрия</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-parity" style="display:none;margin-bottom:14px;font-size:0.82rem;color:var(--text);line-height:1.7;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:8px 11px"></div>
<!-- Notable angles -->
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
@@ -562,8 +626,16 @@
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button>
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/4)">135°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/6)">150°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/6)">210°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/4)">225°</button>
<button class="preset-btn" onclick="trigGoTo(4*Math.PI/3)">240°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/3)">300°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/4)">315°</button>
<button class="preset-btn" onclick="trigGoTo(11*Math.PI/6)">330°</button>
</div>
<!-- Angle info -->
@@ -583,8 +655,10 @@
</div><!-- /.proj-panel -->
<!-- canvas -->
<div class="proj-canvas-outer">
<div class="proj-canvas-outer" style="position:relative">
<canvas id="trigcircle-canvas"></canvas>
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
</div>
</div><!-- /.sim-body-wrap -->
+1 -1
View File
@@ -792,7 +792,7 @@ const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
async function init() {
lucide.createIcons();
const feats = await LS.loadFeatures().catch(() => ({}));
if (feats.red_book === false) { window.location.replace('/403'); return; }
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
// Auth (sidebar)
+2 -1
View File
@@ -196,7 +196,8 @@
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) {
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
+9
View File
@@ -479,6 +479,15 @@
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
function openLabSim(simId, ev) {
if (ev) ev.stopPropagation();
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
try {
const u = LS.getUser && LS.getUser();
if (!(u && u.role === 'admin')) {
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
}
} catch (e) { /* нет кэша — открываем как раньше */ }
location.href = '/lab?sim=' + encodeURIComponent(simId);
}
window.openLabSim = openLabSim;
+386
View File
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Пожелания — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
.container { max-width: 860px; margin: 0 auto; padding: 26px 32px 100px; }
/* ── hero ── */
.wq-hero { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.wq-hero-icon { width: 46px; height: 46px; border-radius: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #9B5DE5, #06B6D4); color: #fff; box-shadow: 0 6px 18px rgba(155,93,229,0.3); }
.wq-hero-txt { flex: 1; min-width: 200px; }
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.18rem; font-weight: 800; color: #0F172A; margin-bottom: 4px; }
.page-sub { font-size: 0.82rem; color: var(--text-3); line-height: 1.5; }
.wq-new-btn { display: inline-flex; align-items: center; gap: 7px; padding: 10px 18px; border-radius: 12px; border: none;
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer;
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 14px rgba(155,93,229,0.28); white-space: nowrap; }
.wq-new-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(155,93,229,0.34); }
.wq-new-btn.open { background: #fff; color: var(--text-2); border: 1.5px solid rgba(15,23,42,0.12); box-shadow: none; }
/* ── submit form (collapsible) ── */
.wq-form { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 18px; padding: 18px 20px; margin-bottom: 22px;
overflow: hidden; max-height: 600px; transition: max-height .3s ease, opacity .25s, padding .25s, margin .25s; }
.wq-form.collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; }
.wq-flabel { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .03em; margin-bottom: 7px; }
.wq-cat-pick { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-cat-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: #fff; font-size: 0.78rem; font-weight: 600; color: var(--text-2); transition: all .15s; }
.wq-cat-opt:hover { border-color: var(--cc); }
.wq-cat-opt.sel { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cat-opt i { width: 14px; height: 14px; }
.wq-inp, .wq-area { width: 100%; padding: 11px 13px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #0F172A; outline: none; transition: border-color .15s; }
.wq-inp:focus, .wq-area:focus { border-color: var(--violet); }
.wq-area { min-height: 74px; resize: vertical; margin-top: 10px; }
.wq-form-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 10px; }
.wq-counter { font-size: 0.72rem; color: var(--text-3); }
/* ── stat / status filter pills ── */
.wq-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.wq-stat { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 13px; cursor: pointer;
background: #fff; border: 1.5px solid rgba(15,23,42,0.07); transition: all .15s; }
.wq-stat:hover { border-color: var(--sc, #9B5DE5); }
.wq-stat.active { border-color: var(--sc, #9B5DE5); background: color-mix(in srgb, var(--sc, #9B5DE5) 9%, #fff); }
.wq-stat-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--sc, #9B5DE5); flex-shrink: 0; }
.wq-stat-lbl { font-size: 0.78rem; font-weight: 600; color: var(--text-2); }
.wq-stat-num { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #0F172A; }
/* ── sub-bar: category filter + search ── */
.wq-subbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.wq-cats { display: flex; gap: 6px; flex-wrap: wrap; }
.wq-cchip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; cursor: pointer;
border: 1.5px solid rgba(15,23,42,0.1); background: transparent; font-size: 0.73rem; font-weight: 600; color: var(--text-3); transition: all .15s; }
.wq-cchip:hover { border-color: var(--cc); color: var(--cc); }
.wq-cchip.active { border-color: var(--cc); background: color-mix(in srgb, var(--cc) 10%, #fff); color: var(--cc); }
.wq-cchip i { width: 12px; height: 12px; }
.wq-search { margin-left: auto; min-width: 180px; flex: 1; max-width: 280px; padding: 8px 13px; border: 1.5px solid rgba(15,23,42,0.1);
border-radius: 11px; font-family: 'Manrope', sans-serif; font-size: 0.82rem; outline: none; transition: border-color .15s; }
.wq-search:focus { border-color: var(--violet); }
/* ── wish cards ── */
.w-list { display: flex; flex-direction: column; gap: 12px; }
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 15px 17px;
display: flex; gap: 13px; transition: box-shadow .15s, transform .15s; animation: wqIn .25s ease both; }
.w-card:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
@keyframes wqIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.w-cat-ic { width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--cc) 13%, #fff); color: var(--cc); }
.w-cat-ic i { width: 19px; height: 19px; }
.w-main { flex: 1; min-width: 0; }
.w-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 3px; }
.w-title { font-size: 0.93rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
.w-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.w-badge i { width: 11px; height: 11px; }
.wb-new { background: rgba(6,182,212,0.12); color: #06aab3; }
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.wb-in_progress{ background: rgba(245,158,11,0.15); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.13); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: #64748B; }
.w-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.w-author { font-weight: 700; color: var(--violet); }
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.55; margin-top: 6px; white-space: pre-wrap; word-break: break-word; }
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
border-radius: 11px; padding: 9px 12px; margin-top: 10px; line-height: 1.5; display: flex; gap: 8px; }
.w-note i { width: 14px; height: 14px; color: var(--violet); flex-shrink: 0; margin-top: 2px; }
/* admin manage */
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
.w-sel { padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px; font-family: 'Manrope', sans-serif;
font-size: 0.8rem; color: #0F172A; cursor: pointer; outline: none; min-width: 150px; }
.w-sel:focus { border-color: var(--violet); }
.w-note-inp { flex: 1; min-width: 200px; padding: 8px 11px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; outline: none; resize: vertical; min-height: 38px; }
.w-note-inp:focus { border-color: var(--violet); }
.w-btn { display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; color: var(--text-2); cursor: pointer; transition: all .15s; }
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.w-btn-icon { padding: 8px; color: var(--text-3); }
.w-btn-icon:hover { background: rgba(239,71,111,0.08); color: #EF476F; border-color: rgba(239,71,111,0.25); }
/* empty / skeleton */
.w-empty { text-align: center; padding: 54px 20px; color: var(--text-3); }
.w-empty-art { width: 80px; height: 80px; margin: 0 auto 14px; border-radius: 22px; display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.08); color: var(--violet); }
.w-empty-art i { width: 38px; height: 38px; }
.w-empty-t { font-size: 0.92rem; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
.w-empty-s { font-size: 0.8rem; }
.w-skel { height: 78px; border-radius: 16px; background: linear-gradient(90deg,#eef0f4 25%,#f6f7f9 50%,#eef0f4 75%); background-size: 200% 100%; animation: wqShim 1.3s infinite; }
@keyframes wqShim { to { background-position: -200% 0; } }
@media (max-width: 600px) {
.container { padding: 16px 14px 80px; }
.wq-new-btn { width: 100%; justify-content: center; }
.wq-search { margin-left: 0; max-width: none; }
.w-card { padding: 13px; gap: 10px; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<div class="wq-hero">
<div class="wq-hero-icon"><i data-lucide="lightbulb" style="width:24px;height:24px"></i></div>
<div class="wq-hero-txt">
<div class="page-title">Пожелания по улучшению</div>
<div class="page-sub" id="w-sub">Есть идея, как сделать систему лучше? Расскажите — мы прочитаем и ответим.</div>
</div>
<button class="wq-new-btn" id="wq-new-btn" onclick="toggleForm()">
<span id="wq-new-ic"><i data-lucide="plus" style="width:15px;height:15px"></i></span> <span id="wq-new-lbl">Поделиться идеей</span>
</button>
</div>
<!-- Submit form -->
<div class="wq-form collapsed" id="wq-form">
<div class="wq-flabel">Категория</div>
<div class="wq-cat-pick" id="wq-cat-pick"></div>
<input class="wq-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" oninput="updCounter()" />
<textarea class="wq-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
<div class="wq-form-foot">
<span class="wq-counter" id="wf-counter">0 / 200</span>
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
<i data-lucide="send" style="width:14px;height:14px"></i> Отправить
</button>
</div>
</div>
<div class="wq-stats" id="wq-stats"></div>
<div class="wq-subbar" id="wq-subbar" style="display:none">
<div class="wq-cats" id="wq-cats"></div>
<input class="wq-search" id="wq-search" placeholder="Поиск по пожеланиям…" oninput="onSearch(this.value)" />
</div>
<div class="w-list" id="w-list">
<div class="w-skel"></div><div class="w-skel"></div><div class="w-skel"></div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
LS.showBoardIfAllowed();
LS.notif.init();
const CAT = {
feature: { label: 'Новая функция', icon: 'sparkles', color: '#9B5DE5' },
ui: { label: 'Интерфейс', icon: 'layout-panel-top', color: '#06B6D4' },
content: { label: 'Контент', icon: 'book-open', color: '#2563EB' },
bug: { label: 'Баг / ошибка', icon: 'bug', color: '#EF476F' },
other: { label: 'Другое', icon: 'message-circle', color: '#64748B' },
};
const CAT_ORDER = ['feature', 'ui', 'content', 'bug', 'other'];
const ST = {
new: { label: 'Новое', icon: 'sparkle', color: '#06aab3' },
planned: { label: 'Запланировано', icon: 'calendar-clock', color: '#9B5DE5' },
in_progress: { label: 'В работе', icon: 'loader', color: '#d97706' },
done: { label: 'Готово', icon: 'check-circle-2', color: '#059652' },
declined: { label: 'Отклонено', icon: 'x-circle', color: '#64748B' },
};
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
let _wishes = [], _statusFilter = null, _catFilter = null, _q = '', _formCat = 'feature', _formOpen = false;
function fmtDate(s) {
if (!s) return '';
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
function icons() { if (window.lucide) lucide.createIcons(); }
/* ── form ── */
function renderCatPick() {
document.getElementById('wq-cat-pick').innerHTML = CAT_ORDER.map(k =>
`<button type="button" class="wq-cat-opt${_formCat === k ? ' sel' : ''}" style="--cc:${CAT[k].color}" onclick="pickCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
icons();
}
function pickCat(k) { _formCat = k; renderCatPick(); }
function updCounter() {
const n = document.getElementById('wf-title').value.length;
document.getElementById('wf-counter').textContent = n + ' / 200';
}
function toggleForm(forceOpen) {
_formOpen = forceOpen === undefined ? !_formOpen : forceOpen;
document.getElementById('wq-form').classList.toggle('collapsed', !_formOpen);
const btn = document.getElementById('wq-new-btn');
btn.classList.toggle('open', _formOpen);
document.getElementById('wq-new-lbl').textContent = _formOpen ? 'Свернуть' : 'Поделиться идеей';
// lucide заменяет <i> на <svg> при рендере, поэтому пере-вставляем свежий <i> в контейнер.
const ic = document.getElementById('wq-new-ic');
if (ic) ic.innerHTML = `<i data-lucide="${_formOpen ? 'chevron-up' : 'plus'}" style="width:15px;height:15px"></i>`;
icons();
if (_formOpen) setTimeout(() => document.getElementById('wf-title').focus(), 80);
}
async function submitWish() {
const title = document.getElementById('wf-title').value.trim();
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
const btn = document.getElementById('wf-submit');
btn.disabled = true;
try {
const row = await LS.wishCreate({ title, category: _formCat, body: document.getElementById('wf-body').value.trim() });
if (isAdmin && user) { row.author_name = user.name; }
_wishes.unshift(row);
document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = '';
_formCat = 'feature'; renderCatPick(); updCounter();
toggleForm(false);
LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null; _catFilter = null;
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
/* ── load + render ── */
async function load() {
try {
const data = await LS.wishesList();
_wishes = data.wishes || [];
renderAll();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty"><div class="w-empty-t">Не удалось загрузить</div><div class="w-empty-s">${esc(e.message || '')}</div></div>`;
}
}
function counts() {
const c = {}; ST_ORDER.forEach(s => c[s] = 0);
_wishes.forEach(w => { c[w.status] = (c[w.status] || 0) + 1; });
return c;
}
function renderAll() { renderStats(); renderSubbar(); renderList(); }
function renderStats() {
const c = counts();
const total = _wishes.length;
let html = `<button class="wq-stat${!_statusFilter ? ' active' : ''}" style="--sc:#9B5DE5" onclick="setStatus(null)">
<span class="wq-stat-lbl">Все</span><span class="wq-stat-num">${total}</span></button>`;
html += ST_ORDER.filter(s => c[s] > 0).map(s =>
`<button class="wq-stat${_statusFilter === s ? ' active' : ''}" style="--sc:${ST[s].color}" onclick="setStatus('${s}')">
<span class="wq-stat-dot"></span><span class="wq-stat-lbl">${ST[s].label}</span><span class="wq-stat-num">${c[s]}</span></button>`).join('');
document.getElementById('wq-stats').innerHTML = html;
}
function renderSubbar() {
const cats = [...new Set(_wishes.map(w => w.category))];
const bar = document.getElementById('wq-subbar');
// показываем подбар только если есть смысл (несколько категорий или много пожеланий)
if (cats.length < 2 && _wishes.length < 4) { bar.style.display = 'none'; return; }
bar.style.display = '';
document.getElementById('wq-cats').innerHTML = CAT_ORDER.filter(k => cats.includes(k)).map(k =>
`<button class="wq-cchip${_catFilter === k ? ' active' : ''}" style="--cc:${CAT[k].color}" onclick="setCat('${k}')">
<i data-lucide="${CAT[k].icon}"></i> ${CAT[k].label}</button>`).join('');
document.getElementById('wq-search').style.display = _wishes.length >= 4 ? '' : 'none';
icons();
}
function setStatus(s) { _statusFilter = (_statusFilter === s) ? null : s; renderAll(); }
function setCat(k) { _catFilter = (_catFilter === k) ? null : k; renderAll(); }
function onSearch(v) { _q = v.trim().toLowerCase(); renderList(); }
function renderList() {
const el = document.getElementById('w-list');
let list = _wishes;
if (_statusFilter) list = list.filter(w => w.status === _statusFilter);
if (_catFilter) list = list.filter(w => w.category === _catFilter);
if (_q) list = list.filter(w =>
(w.title || '').toLowerCase().includes(_q) ||
(w.body || '').toLowerCase().includes(_q) ||
(w.author_name || '').toLowerCase().includes(_q));
if (!list.length) {
const fresh = !_wishes.length;
el.innerHTML = `<div class="w-empty">
<div class="w-empty-art"><i data-lucide="${fresh ? 'lightbulb' : 'search-x'}"></i></div>
<div class="w-empty-t">${fresh ? (isAdmin ? 'Пожеланий пока нет' : 'У вас пока нет пожеланий') : 'Ничего не найдено'}</div>
<div class="w-empty-s">${fresh ? (isAdmin ? 'Они появятся здесь, когда пользователи их оставят.' : 'Поделитесь идеей — нажмите «Поделиться идеей» выше.') : 'Попробуйте изменить фильтр или запрос.'}</div>
</div>`;
icons();
return;
}
el.innerHTML = list.map(cardHtml).join('');
icons();
}
function cardHtml(w) {
const cat = CAT[w.category] || CAT.other;
const st = ST[w.status] || ST.new;
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span><span>·</span>` : '';
const note = w.admin_note ? `<div class="w-note"><i data-lucide="message-square-reply"></i><div><b>Ответ:</b> ${esc(w.admin_note)}</div></div>` : '';
let manage = '';
if (isAdmin) {
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST[s].label}</option>`).join('');
manage = `<div class="w-manage">
<select class="w-sel" id="st-${w.id}">${opts}</select>
<textarea class="w-note-inp" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})"><i data-lucide="check" style="width:13px;height:13px"></i> Сохранить</button>
<button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
</div>`;
} else if (w.status === 'new') {
manage = `<div class="w-manage"><button class="w-btn w-btn-icon" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px"></i> Удалить</button></div>`;
}
return `<div class="w-card" style="--cc:${cat.color}">
<div class="w-cat-ic"><i data-lucide="${cat.icon}"></i></div>
<div class="w-main">
<div class="w-head">
<span class="w-title">${esc(w.title)}</span>
<span class="w-badge wb-${w.status}"><i data-lucide="${st.icon}"></i>${st.label}</span>
</div>
<div class="w-meta">${author}<span>${cat.label}</span><span>·</span><span>${fmtDate(w.created_at)}</span></div>
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
${note}
${manage}
</div>
</div>`;
}
async function saveWish(id) {
try {
const upd = await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value,
admin_note: document.getElementById('note-' + id).value.trim(),
});
const i = _wishes.findIndex(w => w.id === id);
if (i >= 0) { _wishes[i] = { ..._wishes[i], ...upd }; }
LS.toast('Сохранено', 'success');
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function delWish(id) {
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
try {
await LS.wishDelete(id);
_wishes = _wishes.filter(w => w.id !== id);
renderAll();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
renderCatPick();
load();
icons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>
+205 -30
View File
@@ -190,6 +190,12 @@ async function deleteClass(id) { return req('DELETE', `/classes/
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
/* ── Пожелания по улучшению ── */
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
async function wishCreate(data) { return req('POST', '/wishes', data); }
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
@@ -826,10 +832,129 @@ async function loadFeatures() {
_featuresCache = await apiFetch('/api/features');
} catch { _featuresCache = {}; }
_gamificationEnabled = _featuresCache.gamification !== false;
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
return _featuresCache;
}
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; }
/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */
const FEATURE_HREFS = {
hangman: ['/hangman'],
crossword: ['/crossword'],
pet: ['/pet'],
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
collection: ['/collection.html', '/collection'],
lab: ['/lab'],
knowledge_map: ['/knowledge-map'],
flashcards: ['/flashcards'],
board: ['/board'],
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'],
classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
theory: ['/theory', '/theory.html'],
sitemap: ['/sitemap', '/sitemap.html'],
wishes: ['/wishes', '/wishes.html'],
};
/* Контейнеры виджетов-модулей (дашборд и т.п.) прячем блок целиком, а не только
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
Hero-карточки дашборда: у lab JS меняет href на /lab?sim= [href="/lab"] не
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
const FEATURE_WIDGETS = {
flashcards: ['#w-flashcard'],
// #hc-lab — hero-карточка дашборда; .tb-lab-btn — кнопка «открыть связанную
// симуляцию» на карточках каталога учебников (openLabSim → /lab?sim=…). Это
// <button onclick>, а не <a href="/lab">, поэтому [href="/lab"] её не ловит.
lab: ['#hc-lab', '.tb-lab-btn'],
pet: ['#hc-pet'],
textbooks: ['#hc-read'],
};
/* Админ видит и имеет доступ ко ВСЕМУ, даже к отключённым модулям (он ими управляет).
Поэтому для админа никакие скрытия/редиректы фич не применяются. getUser() читает
localStorage синхронно (определён в начале файла) работает и на ранней sync-инъекции. */
function _isAdminUser() {
try { return getUser()?.role === 'admin'; } catch { return false; }
}
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
на ранней загрузке (ДО построения сайдбара/виджетов) против мигания (FOUC),
затем обновляется по свежему /api/features. */
function _applyFeatureCss(feats) {
// Админ — без скрытий: чистим <style> и снимаем kill-switch геймификации.
if (_isAdminUser()) {
const elA = document.getElementById('ls-feat-hide');
if (elA) elA.textContent = '';
document.documentElement.classList.remove('no-gamification');
return;
}
const sels = [];
if (feats) {
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
hrefs.forEach(h => sels.push(`[href="${h}"]`));
(FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s));
}
}
}
// Скрытые exam-prep треки (подготовка): кэш хрефов с прошлой загрузки — против мигания.
// /api/exam-prep/tracks асинхронен, поэтому держим точный список скрытых ссылок в кэше.
try {
JSON.parse(localStorage.getItem('ls_examhide') || '[]')
.forEach(h => sels.push(`[href="${h}"]`));
} catch { /* пусто */ }
let css = sels.length ? sels.join(',') + '{display:none !important}' : '';
// Геймификация: дублируем kill-switch в инъекцию — для страниц БЕЗ ls.css.
// Учебники (frontend/textbooks/*.html) грузят api.js, но НЕ ls.css, поэтому правила
// .no-gamification из ls.css туда не доходят, и встроенная XP-механика (data-gamified,
// #ach-popup) оставалась видимой. Инъекция работает на любой странице с api.js.
if (feats && feats.gamification === false) {
css += '.no-gamification [data-gamified],.no-gamification #ach-popup{display:none!important}';
}
let el = document.getElementById('ls-feat-hide');
if (!el) {
el = document.createElement('style');
el.id = 'ls-feat-hide';
(document.head || document.documentElement).appendChild(el);
}
el.textContent = css;
// Геймификация: класс на <html> (доступен раньше body) → kill-switch без мигания.
if (feats) document.documentElement.classList.toggle('no-gamification', feats.gamification === false);
}
/* Ранняя синхронная попытка из кэша прошлой загрузки нет мигания на повторных заходах.
(FEATURE_HREFS const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */
try {
const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
_applyFeatureCss(_cachedFeats); // применит и кэш фич, и кэш скрытых exam-prep ссылок
} catch { /* нет кэша / приватный режим — просто ждём async */ }
/* Авторитетно подтянуть фичи на страницах БЕЗ сайдбара (учебники, embed): там
sidebar.js/hideDisabledFeatures не вызывают loadFeatures, и кэш мог устареть.
loadFeatures() кэширует in-memory (дубль-вызов = один fetch) и сам зовёт _applyFeatureCss.
Только для залогиненных иначе на /login apiFetch поймает 401 и зациклит редирект. */
try {
if (isLoggedIn()) { loadFeatures().catch(() => {}); }
} catch { /* defensive */ }
/* Прячет группы сайдбара (.sb-group), у которых не осталось ни одного видимого пункта,
чтобы не висел пустой заголовок-аккордеон (напр. «Практика и игры», когда все
модули отключены). Зовётся после построения сайдбара и после hideDisabledFeatures. */
function hideEmptySidebarGroups() {
document.querySelectorAll('.sb-group').forEach(g => {
const body = g.querySelector('.sb-group-body');
if (!body) return;
let anyVisible = false;
body.querySelectorAll('.sb-link').forEach(it => {
const cs = getComputedStyle(it);
if (cs.display !== 'none' && cs.visibility !== 'hidden') anyVisible = true;
});
g.style.display = anyVisible ? '' : 'none';
});
}
/**
* Show board sidebar link only for teachers/admins and students in a class.
* Call after LS.initPage(). Uses features cache (_no_class flag).
@@ -839,38 +964,25 @@ async function showBoardIfAllowed() {
if (!el) return;
const user = getUser();
if (!user) return;
if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; }
// Student: check if in a class
// Админ видит доску всегда (даже если фича отключена) — он ею управляет.
if (user.role === 'admin') { el.style.display = ''; return; }
const feats = await loadFeatures();
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю.
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
// иначе она перекрывает скрытие из hideDisabledFeatures().
if (feats.board === false) { el.style.display = 'none'; return; }
if (user.role === 'teacher') { el.style.display = ''; return; }
// Student: check if in a class
if (!feats._no_class) el.style.display = '';
}
async function hideDisabledFeatures() {
const feats = await loadFeatures();
const map = {
hangman: ['/hangman'],
crossword: ['/crossword'],
pet: ['/pet'],
red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'],
collection: ['/collection.html', '/collection'],
lab: ['/lab'],
knowledge_map: ['/knowledge-map'],
flashcards: ['/flashcards'],
board: ['/board'],
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'],
classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
};
for (const [key, hrefs] of Object.entries(map)) {
const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
// Админ видит и открывает всё — никаких скрытий, редиректов и схлопывания групп.
if (_isAdminUser()) return;
// Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
hrefs.forEach(href => {
document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none');
});
// Redirect away if currently on a disabled page
const cur = window.location.pathname;
if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) {
window.location.href = '/dashboard.html';
@@ -878,7 +990,7 @@ async function hideDisabledFeatures() {
}
}
if (feats.gamification === false) {
document.body.classList.add('no-gamification');
document.body.classList.add('no-gamification'); // дубль на body (html-класс ставит _applyFeatureCss)
// If student is already viewing achievements or shop tab, redirect to account tab
const active = document.querySelector('#tab-achievements.active, #tab-shop.active');
if (active) {
@@ -894,10 +1006,16 @@ async function hideDisabledFeatures() {
try {
const data = await apiFetch('/api/exam-prep/tracks');
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
// Собираем точные хрефы скрытых треков и кэшируем — чтобы на СЛЕДУЮЩЕЙ загрузке
// _applyFeatureCss спрятал их синхронно из кэша ещё до сборки сайдбара (без мигания).
const hide = [];
examLinks.forEach(el => {
const m = (el.getAttribute('href') || '').match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) el.style.display = 'none';
const href = el.getAttribute('href') || '';
const m = href.match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) hide.push(href);
});
try { localStorage.setItem('ls_examhide', JSON.stringify(hide)); } catch {}
_applyFeatureCss(_featuresCache); // обновить <style> (скрыть запрещённые, ПОКАЗАТЬ снова разрешённые)
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
@@ -928,6 +1046,9 @@ async function hideDisabledFeatures() {
document.body.classList.add('no-class');
document.body.classList.add('no-gamification'); // no class <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> no gamification
}
// В самом конце — после всех скрытий (фичи, exam-prep, no_class) — схлопнуть пустые группы.
hideEmptySidebarGroups();
}
/* ── generic authenticated fetch (full path like /api/courses) ─────── */
@@ -1029,7 +1150,8 @@ window.LS = {
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal,
regenerateInviteCode, classJournal, classOutstanding,
wishesList, wishCreate, wishUpdate, wishDelete,
joinClass, myClasses, getStudents, classFeed,
getAnnouncements, createAnnouncement, deleteAnnouncement,
getNotifications, markNotifRead, markAllNotifsRead, connectSSE,
@@ -1089,6 +1211,7 @@ window.LS = {
loadFeatures,
clearFeaturesCache,
hideDisabledFeatures,
hideEmptySidebarGroups,
showBoardIfAllowed,
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
@@ -1854,3 +1977,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
});
});
})();
/* Глобальный репортер клиентских ошибок
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
и шлёт в /api/client-errors они появляются в админ-вкладке «Ошибки».
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
(function initClientErrorReporter() {
const seen = new Set();
let sent = 0; const MAX_PER_PAGE = 15;
let inFlight = false;
function send(payload) {
try {
if (!isLoggedIn()) return; // отчёты только от залогиненных
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
if (seen.has(sig)) return;
seen.add(sig); sent++;
if (inFlight) return;
inFlight = true;
const token = getToken();
fetch(API + '/client-errors', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
body: JSON.stringify(payload),
keepalive: true, // долетит даже при закрытии вкладки
}).catch(function () {}).finally(function () { inFlight = false; });
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
}
window.addEventListener('error', function (e) {
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
if (!e || (!e.message && !e.error)) return;
send({
kind: 'error',
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
stack: e.error && e.error.stack ? String(e.error.stack) : null,
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
url: location.pathname + location.search + location.hash,
});
});
window.addEventListener('unhandledrejection', function (e) {
const r = e && e.reason;
let msg = 'Unhandled promise rejection';
let stack = null;
if (r) {
if (typeof r === 'string') msg = r;
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
}
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
});
})();
+4
View File
@@ -62,6 +62,7 @@
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
${L('/dashboard', 'home', 'Дашборд')}
${L('/sitemap', 'map', 'Путеводитель')}
${L('/wishes', 'lightbulb', 'Пожелания')}
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
${G('learning', 'Учебный процесс', `
@@ -228,6 +229,9 @@
LS.showBoardIfAllowed?.();
LS.hideDisabledFeatures?.();
LS.notif?.init?.();
// Синхронно по кэш-состоянию (CSS уже инъектнут до сборки) — прячем пустые
// группы сразу, без мигания; hideDisabledFeatures повторит после свежих данных.
LS.hideEmptySidebarGroups?.();
}
// Глобальная плавающая кнопка «создать карточку» (на всех страницах с шапкой)
+49
View File
@@ -0,0 +1,49 @@
# Тригонометрическая окружность — план улучшения (тренажёр темы, без функций)
Цель: симуляция `frontend/js/labs/trigcircle.js` + панель `frontend/labs-bodies.html` (#sim-trigcircle)
покрывает всю школьную тригонометрию НА ОКРУЖНОСТИ. Графики y=f(x) («функции») — вне темы:
существующий showGraph оставляем опциональным/скрываемым.
Архитектура: рукописный canvas-sim (класс TrigCircleSim) + HTML-панель в labs-bodies.html +
glue-функции (`_openTrigCircle`, `trigToggle`, `trigGoTo`, `trigReset`, `_trigUpdateUI`) внизу
trigcircle.js; регистрация в `_register-all.js` (`trigcircle`). KaTeX, LabFX, _tasks.js доступны.
⛔ без eval, без эмодзи (inline SVG .ic), всё аддитивно (не ломать текущий режим).
## Уже есть
Окружность, перетаскиваемая точка, угол °/рад (метки π/6…), sin/cos/tan/cot отрезками (слои),
треугольник sin-cos, касательная/котангенс, 16 табличных углов + snap, подсветка четверти,
значения дробями (½,√2/2,√3/2,√3/3,√3), stat-bar, опциональный график (= «функции»).
## Статус (на 2026-06-24) — ВСЕ ОСНОВНЫЕ ФАЗЫ ГОТОВЫ
- ✅ Ф1 (углы: ввод, котерминальность, 16 углов, опорный угол, знаки) — d395e10
- ✅ Ф2 (точные значения + формулы приведения для текущего угла) — 5eed248
- ✅ KaTeX: формулы — cefb5e0; ВСЯ панель (значения, угол, таблица) — 244df71
- ✅ Ф3 (знаки по четвертям): _quadSigns на canvas (текущая четверть подсвечена) +
панельная строка знаков (Ф1). Доп. работы не потребовалось.
- ✅ Ф4 (таблица значений 0–90° на KaTeX, подсветка опорного угла) — fe6df8f
- ✅ Ф5 (чётность −α: зеркальная точка + sin/cos/tg(−α) + периоды) — 48158ea;
формулы приведения — Ф2.
- ✅ Ф6 (простейшие уравнения fn(x)=a: все решения на круге + общая формула KaTeX) — dfa0535
- Уже было на canvas до плана: Пифагор (_pythBar), отрезки sin/cos/tg/ctg, координаты
точки, табличные точки+snap, опц. график функций.
- Осталось (опционально): режимы-вкладки + задания (_tasks.js); sec/csc; два угла (Ф7).
## Фазы
- **Ф1 — Углы и обзор**: тумблер скрыть график (фокус на круге); ввод угла (° и π-доли);
полная сетка табличных кнопок (16); опорный (острый) угол; знаки по четвертям в выводе;
подсказка котерминальности (+360°k / +2πk).
- **Ф2 — Определения / 6 функций**: подписи sin=y, cos=x на осях; слой sec/csc; Пифагор
sin²+cos²=1 (гипотенуза=1) с формулой; тумблер «формула значения» (KaTeX).
- **Ф3 — Знаки**: режим со знаками +/− sin/cos/tg по четвертям, мнемоника, таблица.
- **Ф4 — Особые углы / таблица значений**: оверлей-таблица 0/30/45/60/90… с подсветкой текущего.
- **Ф5 — Симметрии и формулы приведения**: чётность (α→−α), приведение (π±α, π/2±α, 2π−α)
с анимацией отражения/поворота + KaTeX; период tg/ctg = π.
- **Ф6 — Простейшие уравнения**: задаёшь значение → все решения на круге + общая формула
(sin α=½ → π/6+2πk, 5π/6+2πk); для tg — шаг π; связка с arcsin/arccos геометрически.
- **Ф7 — Два угла (опц.)**: вторая точка β → α±β, формулы сложения.
- **Сквозное**: режимы-вкладки (Углы·Определения·Знаки·Особые·Приведение·Уравнения) с краткой
теорией и кнопкой «Задание» через _tasks.js; шпаргалка (значения+знаки+тождества+приведение).
## Проверка каждой фазы
node --check; headless-смоук математики (опорный угол, знаки, решения уравнений, приведение)
в vm с стабом canvas; коммит+push, без эмодзи, lint.
+26
View File
@@ -183,6 +183,30 @@ function Backup-Db {
Prune-Backups
}
function Reset-System {
Write-Host ''
Write-Host ' ============================================================' -ForegroundColor Red
Write-Host ' ВНИМАНИЕ: ЧИСТЫЙ ЗАПУСК - НЕОБРАТИМАЯ ОЧИСТКА' -ForegroundColor Red
Write-Host ' ============================================================' -ForegroundColor Red
Write-Host ' УДАЛЯТСЯ: все пользователи (кроме одного админа), классы,' -ForegroundColor Yellow
Write-Host ' задания, сессии, геймификация, уведомления, прогресс, история.' -ForegroundColor Yellow
Write-Host ' СОХРАНЯТСЯ: учебники, вопросы, тесты, курсы, уроки, exam-prep,' -ForegroundColor Gray
Write-Host ' симуляции, настройки/права и один админ (контент переходит ему).' -ForegroundColor Gray
Write-Host ''
Write-Host ' План (предпросмотр, без изменений):' -ForegroundColor Cyan
try { & node scripts/reset-system.js | Out-Host } catch { Write-Host (' Ошибка плана: ' + $_.Exception.Message) -ForegroundColor Red; return }
Write-Host ''
$ans = (Read-Host ' Для подтверждения введите СБРОС (иначе отмена)').Trim().ToUpper()
if ($ans -ne 'СБРОС' -and $ans -ne 'RESET') { Write-Host ' Отменено.' -ForegroundColor Yellow; return }
if (Server-Proc) { Write-Host ' Сервер работает - остановите его ([2]) перед сбросом для надёжности.' -ForegroundColor Yellow }
Write-Host ' Шаг 1/2: бэкап БД...' -ForegroundColor Cyan
Backup-Db
Write-Host ' Шаг 2/2: очистка...' -ForegroundColor Cyan
try { & node scripts/reset-system.js --apply --confirm=RESET | Out-Host }
catch { Write-Host (' Ошибка сброса: ' + $_.Exception.Message) -ForegroundColor Red; return }
Write-Host ' Готово. Перезапустите сервер ([3]). Бэкап сохранён в data\backups.' -ForegroundColor Green
}
function Restore-Db {
if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return }
$files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
@@ -389,6 +413,7 @@ while ($run) {
Menu-Row '[5]' 'Применить миграции' '[A]' 'Создать админа'
Menu-Row ' ' '' '[W]' 'Сторож (авто-рестарт)'
Menu-Row ' ' '' '[E]' 'Ошибки в логах'
Menu-Row ' ' '' '[Z]' 'Сброс системы (чистый запуск)'
Write-Host ''
Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' ''
Write-Host ' ' -NoNewline
@@ -417,6 +442,7 @@ while ($run) {
'^(U|Г)$' { Run-Cmd 'Обновление из репозитория' { Update-FromRepo }; Refresh-Status }
'^(W|Ц)$' { Watchdog; Refresh-Status }
'^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } }
'^(Z|Я)$' { Reset-System; Refresh-Status; Start-Sleep 1 }
'^0$' { $run = $false }
default { }
}